From db388bd3cb265fe5016129400ef049f6a2ccb3cd Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Wed, 8 Apr 2026 13:57:56 +0200 Subject: [PATCH 01/37] few more udpates for new categories --- .github/copilot-instructions.md | 3 + notebooks/bug-fix/overview.ipynb | 4 +- src/bcbench/agent/mini/agent.py | 4 +- src/bcbench/agent/shared/prompt.py | 13 +++- src/bcbench/dataset/dataset_entry.py | 16 ++-- tests/test_get_task.py | 106 --------------------------- 6 files changed, 25 insertions(+), 121 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 98156aef5..76b60e234 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,6 +14,9 @@ This is a benchmark for evaluating coding agents on real-world Business Central - Uses `uv` for dependency management: e.g. `uv add ` to add packages, `uv run ` to run commands - Uses `pre-commit` for code quality checks (ruff linting/formatting, trailing whitespace, etc.) +## Categories +BC-Bench is category-based and designed to grow over time. It currently has two categories, `bug-fix` and `test-generation`. They share the same dataset tasks and execution-based setup, but use different prompts, expected outputs, and evaluation pipelines. Future categories such as `code-review` can be added within the same overall benchmark structure, though they may require different inputs, setup, or evaluation methods. + ## Coding Patterns and Guidelines - Prefer strong typing and type hints diff --git a/notebooks/bug-fix/overview.ipynb b/notebooks/bug-fix/overview.ipynb index 49211055d..8e31e3804 100644 --- a/notebooks/bug-fix/overview.ipynb +++ b/notebooks/bug-fix/overview.ipynb @@ -269,7 +269,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "8b5bb1be", "metadata": {}, "outputs": [ @@ -291,7 +291,7 @@ "merged_df[\"image_bin\"] = pd.cut(merged_df[\"image_count\"], bins=bins, labels=labels)\n", "\n", "# Add problem statement char count\n", - "ps_chars = {entry.instance_id: len(entry.get_task(transform_image_paths=False)) for entry in bcbench_dataset}\n", + "ps_chars = {entry.instance_id: len(entry.get_task()) for entry in bcbench_dataset}\n", "merged_df[\"ps_chars\"] = merged_df[\"instance_id\"].map(ps_chars)\n", "\n", "instance_df = (\n", diff --git a/src/bcbench/agent/mini/agent.py b/src/bcbench/agent/mini/agent.py index fec150331..0fbba7088 100644 --- a/src/bcbench/agent/mini/agent.py +++ b/src/bcbench/agent/mini/agent.py @@ -73,7 +73,9 @@ def run_mini_agent( logger.info(f"Running mini-bc-agent on: {entry.instance_id}") - task: str = entry.get_task(transform_image_paths=True) + from bcbench.agent.shared.prompt import _transform_image_paths + + task: str = _transform_image_paths(entry.get_task()) # Lazy import and create agent from minisweagent.models.litellm_model import LitellmModel diff --git a/src/bcbench/agent/shared/prompt.py b/src/bcbench/agent/shared/prompt.py index 474105993..1dd36d360 100644 --- a/src/bcbench/agent/shared/prompt.py +++ b/src/bcbench/agent/shared/prompt.py @@ -1,10 +1,19 @@ +import re from pathlib import Path from jinja2 import Template +from bcbench.config import get_config from bcbench.dataset import BaseDatasetEntry from bcbench.types import EvaluationCategory +_config = get_config() + + +def _transform_image_paths(content: str) -> str: + dest_dir = _config.file_patterns.problem_statement_dest_dir + return re.sub(r"!\[([^\]]*)\]\(\./([^)]+)\)", rf"![\1]({dest_dir}/\2)", content) + def build_prompt(entry: BaseDatasetEntry, repo_path: Path, config: dict, category: EvaluationCategory, al_mcp: bool = False) -> str: prompt_config = config.get("prompt", {}) @@ -15,10 +24,12 @@ def build_prompt(entry: BaseDatasetEntry, repo_path: Path, config: dict, categor is_gold_patch: bool = category == EvaluationCategory.TEST_GENERATION and test_gen_input in ("gold-patch", "both") is_problem_statement: bool = category == EvaluationCategory.TEST_GENERATION and test_gen_input in ("problem-statement", "both") + task = _transform_image_paths(entry.get_task()) + template = Template(template_str) return template.render( repo_path=repo_path, - task=entry.get_task(transform_image_paths=True), + task=task, project_paths=", ".join(entry.project_paths), include_project_paths=include_project_paths, is_gold_patch=is_gold_patch, # only relevant for test-generation diff --git a/src/bcbench/dataset/dataset_entry.py b/src/bcbench/dataset/dataset_entry.py index 2d073a156..c2620caa0 100644 --- a/src/bcbench/dataset/dataset_entry.py +++ b/src/bcbench/dataset/dataset_entry.py @@ -37,12 +37,12 @@ class BaseDatasetEntry(BaseModel): metadata: EntryMetadata = Field(default_factory=EntryMetadata) - repo: str = Field(default="microsoftInternal/NAV", pattern=r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$") + repo: str = Field(default="microsoft/BCApps", pattern=r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$") instance_id: str = Field(pattern=_config.file_patterns.instance_pattern) base_commit: str = Field(pattern=r"^[a-fA-F0-9]{40}$") created_at: Annotated[str, Field(min_length=1)] environment_setup_version: str = Field(pattern=r"^[0-9]{2}\.[0-9]{1}$") - project_paths: Annotated[list[str], Field(min_length=2)] + project_paths: list[str] = [] patch: Annotated[str, Field(min_length=1)] @classmethod @@ -85,7 +85,7 @@ def save_to_file(self, filepath: Path | str) -> None: handle.write("\n") @abstractmethod - def get_task(self, transform_image_paths: bool = False) -> str: + def get_task(self) -> str: pass @abstractmethod @@ -116,15 +116,9 @@ class _BugFixTestGenBase(BaseDatasetEntry): def problem_statement_dir(self) -> Path: return _config.paths.problem_statement_dir / self.instance_id - def get_task(self, transform_image_paths: bool = False) -> str: + def get_task(self) -> str: readme_path = self.problem_statement_dir / _config.file_patterns.problem_statement_readme - content: str = readme_path.read_text(encoding="utf-8") - - if not transform_image_paths: - return content - - dest_dir = _config.file_patterns.problem_statement_dest_dir - return re.sub(r"!\[([^\]]*)\]\(\./([^)]+)\)", rf"![\1]({dest_dir}/\2)", content) + return readme_path.read_text(encoding="utf-8") @model_validator(mode="after") def validate_baseapp_patches_are_w1_only(self) -> Self: diff --git a/tests/test_get_task.py b/tests/test_get_task.py index 783cc9418..64a2f97df 100644 --- a/tests/test_get_task.py +++ b/tests/test_get_task.py @@ -14,109 +14,3 @@ def test_returns_readme_content(self, tmp_path: Path): result = entry.get_task() assert result == content - - def test_transform_image_paths_false_preserves_relative_paths(self, tmp_path: Path): - content = "# Task\n\n![diagram](./diagram.png)\n\nSome text." - problem_dir = create_problem_statement_dir(tmp_path, content) - entry = create_dataset_entry() - - with patch.object(type(entry), "problem_statement_dir", property(lambda self: problem_dir)): - result = entry.get_task(transform_image_paths=False) - - assert "![diagram](./diagram.png)" in result - - def test_transform_image_paths_true_converts_to_problem_directory(self, tmp_path: Path): - content = "# Task\n\n![diagram](./diagram.png)\n\nSome text." - problem_dir = create_problem_statement_dir(tmp_path, content) - entry = create_dataset_entry() - - with patch.object(type(entry), "problem_statement_dir", property(lambda self: problem_dir)): - result = entry.get_task(transform_image_paths=True) - - assert "![diagram](problem/diagram.png)" in result - assert "./diagram.png" not in result - - def test_transform_image_paths_handles_multiple_images(self, tmp_path: Path): - content = "# Task\n\n![first](./img1.png)\n\nText\n\n![second](./img2.png)" - problem_dir = create_problem_statement_dir(tmp_path, content) - entry = create_dataset_entry() - - with patch.object(type(entry), "problem_statement_dir", property(lambda self: problem_dir)): - result = entry.get_task(transform_image_paths=True) - - assert "![first](problem/img1.png)" in result - assert "![second](problem/img2.png)" in result - - def test_transform_image_paths_preserves_alt_text(self, tmp_path: Path): - content = "![Complex Alt Text with spaces](./image.png)" - problem_dir = create_problem_statement_dir(tmp_path, content) - entry = create_dataset_entry() - - with patch.object(type(entry), "problem_statement_dir", property(lambda self: problem_dir)): - result = entry.get_task(transform_image_paths=True) - - assert "![Complex Alt Text with spaces](problem/image.png)" in result - - def test_transform_image_paths_handles_empty_alt_text(self, tmp_path: Path): - content = "![](./image.png)" - problem_dir = create_problem_statement_dir(tmp_path, content) - entry = create_dataset_entry() - - with patch.object(type(entry), "problem_statement_dir", property(lambda self: problem_dir)): - result = entry.get_task(transform_image_paths=True) - - assert "![](problem/image.png)" in result - - def test_transform_image_paths_handles_nested_paths(self, tmp_path: Path): - content = "![diagram](./images/subdir/diagram.png)" - problem_dir = create_problem_statement_dir(tmp_path, content) - entry = create_dataset_entry() - - with patch.object(type(entry), "problem_statement_dir", property(lambda self: problem_dir)): - result = entry.get_task(transform_image_paths=True) - - assert "![diagram](problem/images/subdir/diagram.png)" in result - - def test_transform_image_paths_ignores_absolute_urls(self, tmp_path: Path): - content = "![external](https://example.com/image.png)" - problem_dir = create_problem_statement_dir(tmp_path, content) - entry = create_dataset_entry() - - with patch.object(type(entry), "problem_statement_dir", property(lambda self: problem_dir)): - result = entry.get_task(transform_image_paths=True) - - assert "![external](https://example.com/image.png)" in result - - def test_transform_image_paths_ignores_non_relative_paths(self, tmp_path: Path): - content = "![other](images/diagram.png)" - problem_dir = create_problem_statement_dir(tmp_path, content) - entry = create_dataset_entry() - - with patch.object(type(entry), "problem_statement_dir", property(lambda self: problem_dir)): - result = entry.get_task(transform_image_paths=True) - - # Paths without ./ prefix should not be transformed - assert "![other](images/diagram.png)" in result - - def test_transform_image_paths_handles_mixed_content(self, tmp_path: Path): - content = """# Problem - -![local](./diagram.png) - -Some text with [a link](./doc.md) that is not an image. - -![external](https://example.com/img.png) - -![another local](./screenshot.jpg) -""" - problem_dir = create_problem_statement_dir(tmp_path, content) - entry = create_dataset_entry() - - with patch.object(type(entry), "problem_statement_dir", property(lambda self: problem_dir)): - result = entry.get_task(transform_image_paths=True) - - assert "![local](problem/diagram.png)" in result - assert "![another local](problem/screenshot.jpg)" in result - assert "![external](https://example.com/img.png)" in result - # Regular links should be preserved (not images) - assert "[a link](./doc.md)" in result From 57c004e95627010dc35a6b535b280c59ec8d8bd9 Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Thu, 9 Apr 2026 10:22:17 +0200 Subject: [PATCH 02/37] Refactor evaluation and dataset operations for improved workspace setup --- src/bcbench/commands/dataset.py | 5 +- src/bcbench/commands/run.py | 14 ++--- src/bcbench/evaluate/base.py | 9 ++++ src/bcbench/evaluate/bugfix.py | 9 +++- src/bcbench/evaluate/testgeneration.py | 41 +++++++++++++-- src/bcbench/operations/__init__.py | 3 +- src/bcbench/operations/setup_operations.py | 61 +++------------------- tests/test_testgeneration_validation.py | 2 +- 8 files changed, 70 insertions(+), 74 deletions(-) diff --git a/src/bcbench/commands/dataset.py b/src/bcbench/commands/dataset.py index 732faee7a..678c9502e 100644 --- a/src/bcbench/commands/dataset.py +++ b/src/bcbench/commands/dataset.py @@ -93,8 +93,9 @@ def view_entry( metadata_dict = entry.metadata.model_dump() for field_name, field_value in metadata_dict.items(): - display_name = field_name.replace("_", " ").title() - info_table.add_row(f"[dim]Metadata:[/dim] {display_name}", str(field_value) if field_value else "N/A") + if field_value is not None: + display_name = field_name.replace("_", " ").title() + info_table.add_row(f"[dim]Metadata:[/dim] {display_name}", str(field_value)) console.print(Panel(info_table, title="[bold]Entry Information[/bold]", border_style="blue")) diff --git a/src/bcbench/commands/run.py b/src/bcbench/commands/run.py index 38832a1b8..4b069155d 100644 --- a/src/bcbench/commands/run.py +++ b/src/bcbench/commands/run.py @@ -19,9 +19,7 @@ RepoPath, ) from bcbench.config import get_config -from bcbench.dataset.dataset_entry import _BugFixTestGenBase from bcbench.logger import get_logger -from bcbench.operations import setup_repo_postbuild, setup_repo_prebuild logger = get_logger(__name__) _config = get_config() @@ -46,9 +44,7 @@ def run_mini( uv run bcbench run mini microsoft__BCApps-5633 --step-limit 5 --category bug-fix """ entry = category.entry_class.load(category.dataset_path, entry_id=entry_id)[0] - setup_repo_prebuild(entry, repo_path) - if isinstance(entry, _BugFixTestGenBase): - setup_repo_postbuild(entry, repo_path, category) + category.pipeline.setup_workspace(entry, repo_path) run_mini_agent( entry=entry, @@ -78,9 +74,7 @@ def run_copilot( uv run bcbench run copilot microsoft__BCApps-5633 --category bug-fix --repo-path /path/to/BCApps """ entry = category.entry_class.load(category.dataset_path, entry_id=entry_id)[0] - setup_repo_prebuild(entry, repo_path) - if isinstance(entry, _BugFixTestGenBase): - setup_repo_postbuild(entry, repo_path, category) + category.pipeline.setup_workspace(entry, repo_path) run_copilot_agent(entry=entry, repo_path=repo_path, model=model, category=category, output_dir=output_dir, al_mcp=al_mcp, container_name=container_name) @@ -104,9 +98,7 @@ def run_claude( uv run bcbench run claude microsoft__BCApps-5633 --category bug-fix --repo-path /path/to/BCApps """ entry = category.entry_class.load(category.dataset_path, entry_id=entry_id)[0] - setup_repo_prebuild(entry, repo_path) - if isinstance(entry, _BugFixTestGenBase): - setup_repo_postbuild(entry, repo_path, category) + category.pipeline.setup_workspace(entry, repo_path) run_claude_code(entry=entry, repo_path=repo_path, model=model, category=category, output_dir=output_dir, al_mcp=al_mcp, container_name=container_name) diff --git a/src/bcbench/evaluate/base.py b/src/bcbench/evaluate/base.py index 8c2dbb2d6..fd7850354 100644 --- a/src/bcbench/evaluate/base.py +++ b/src/bcbench/evaluate/base.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable +from pathlib import Path from bcbench.config import get_config from bcbench.dataset import BaseDatasetEntry @@ -23,6 +24,14 @@ class EvaluationPipeline[E: BaseDatasetEntry](ABC): The execute() method provides a template orchestrating the overall evaluation flow. """ + @abstractmethod + def setup_workspace(self, entry: E, repo_path: Path) -> None: + """Prepare the workspace for agent execution (no build). + + Used by the `run` command to set up the repo without building. + """ + raise NotImplementedError() + @abstractmethod def setup(self, context: EvaluationContext[E]) -> None: """Setup environment: e.g. clean repo, checkout base commit, initial build. diff --git a/src/bcbench/evaluate/bugfix.py b/src/bcbench/evaluate/bugfix.py index 9df7eee67..b575407fe 100644 --- a/src/bcbench/evaluate/bugfix.py +++ b/src/bcbench/evaluate/bugfix.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from pathlib import Path from bcbench.dataset import BugFixEntry from bcbench.evaluate.base import EvaluationPipeline @@ -9,8 +10,8 @@ build_and_publish_projects, categorize_projects, clean_project_paths, + copy_problem_statement_folder, run_tests, - setup_repo_postbuild, setup_repo_prebuild, stage_and_get_diff, ) @@ -25,6 +26,10 @@ class BugFixPipeline(EvaluationPipeline[BugFixEntry]): """Pipeline for bug-fix evaluation category.""" + def setup_workspace(self, entry: BugFixEntry, repo_path: Path) -> None: + setup_repo_prebuild(entry, repo_path) + copy_problem_statement_folder(entry, repo_path) + def setup(self, context: EvaluationContext[BugFixEntry]) -> None: setup_repo_prebuild(context.entry, context.repo_path) @@ -35,7 +40,7 @@ def setup(self, context: EvaluationContext[BugFixEntry]) -> None: context.entry.environment_setup_version, ) - setup_repo_postbuild(context.entry, context.repo_path, context.category) + copy_problem_statement_folder(context.entry, context.repo_path) def run_agent(self, context: EvaluationContext[BugFixEntry], agent_runner: Callable) -> None: with github_log_group(f"{context.agent_name} -- Entry: {context.entry.instance_id}"): diff --git a/src/bcbench/evaluate/testgeneration.py b/src/bcbench/evaluate/testgeneration.py index 11642dee1..f0e3848ee 100644 --- a/src/bcbench/evaluate/testgeneration.py +++ b/src/bcbench/evaluate/testgeneration.py @@ -1,6 +1,10 @@ from collections.abc import Callable +from pathlib import Path + +import yaml from bcbench.collection.patch_utils import extract_file_paths_from_patch +from bcbench.config import get_config from bcbench.dataset import TestEntry, TestGenEntry from bcbench.evaluate.base import EvaluationPipeline from bcbench.exceptions import BuildError, NoTestsExtractedError, TestExecutionError @@ -10,8 +14,8 @@ build_and_publish_projects, categorize_projects, clean_project_paths, + copy_problem_statement_folder, extract_tests_from_patch, - setup_repo_postbuild, setup_repo_prebuild, stage_and_get_diff, ) @@ -20,13 +24,44 @@ from bcbench.types import EvaluationContext logger = get_logger(__name__) +_config = get_config() + +__all__ = ["TestGenerationPipeline", "_get_test_generation_input_mode"] + -__all__ = ["TestGenerationPipeline"] +def _get_test_generation_input_mode() -> str: + config_file: Path = _config.paths.agent_share_dir / "config.yaml" + shared_config = yaml.safe_load(config_file.read_text()) + input_mode: str = shared_config.get("prompt", {}).get("test-generation-input", "problem-statement") + + valid_modes: set[str] = {"gold-patch", "problem-statement", "both"} + if input_mode not in valid_modes: + raise ValueError(f"Invalid test-generation-input mode: '{input_mode}'. Must be one of {valid_modes}. Note: Use hyphens, not underscores (e.g., 'gold-patch' not 'gold_patch')") + + return input_mode class TestGenerationPipeline(EvaluationPipeline[TestGenEntry]): """Pipeline for test-generation evaluation category.""" + def _apply_input_postbuild(self, entry: TestGenEntry, repo_path: Path) -> None: + input_mode = _get_test_generation_input_mode() + logger.info(f"Test generation input mode: {input_mode}") + match input_mode: + case "gold-patch": + apply_patch(repo_path, entry.patch, f"{entry.instance_id} gold patch") + case "both": + apply_patch(repo_path, entry.patch, f"{entry.instance_id} gold patch") + copy_problem_statement_folder(entry, repo_path) + case "problem-statement": + copy_problem_statement_folder(entry, repo_path) + case _: + raise ValueError(f"Unhandled test generation input mode: {input_mode}") + + def setup_workspace(self, entry: TestGenEntry, repo_path: Path) -> None: + setup_repo_prebuild(entry, repo_path) + self._apply_input_postbuild(entry, repo_path) + def setup(self, context: EvaluationContext[TestGenEntry]) -> None: setup_repo_prebuild(context.entry, context.repo_path) @@ -37,7 +72,7 @@ def setup(self, context: EvaluationContext[TestGenEntry]) -> None: context.entry.environment_setup_version, ) - setup_repo_postbuild(context.entry, context.repo_path, context.category) + self._apply_input_postbuild(context.entry, context.repo_path) def run_agent(self, context: EvaluationContext[TestGenEntry], agent_runner: Callable) -> None: with github_log_group(f"{context.agent_name} -- Entry: {context.entry.instance_id}"): diff --git a/src/bcbench/operations/__init__.py b/src/bcbench/operations/__init__.py index 05fd52171..45d7dcf5e 100644 --- a/src/bcbench/operations/__init__.py +++ b/src/bcbench/operations/__init__.py @@ -17,7 +17,7 @@ ) from bcbench.operations.instruction_operations import copy_problem_statement_folder, setup_custom_agent, setup_instructions_from_config from bcbench.operations.project_operations import categorize_projects -from bcbench.operations.setup_operations import setup_repo_postbuild, setup_repo_prebuild +from bcbench.operations.setup_operations import setup_repo_prebuild from bcbench.operations.skills_operations import setup_agent_skills from bcbench.operations.test_operations import extract_tests_from_patch @@ -38,7 +38,6 @@ "setup_agent_skills", "setup_custom_agent", "setup_instructions_from_config", - "setup_repo_postbuild", "setup_repo_prebuild", "stage_and_get_diff", ] diff --git a/src/bcbench/operations/setup_operations.py b/src/bcbench/operations/setup_operations.py index bc502ff9f..4682ed721 100644 --- a/src/bcbench/operations/setup_operations.py +++ b/src/bcbench/operations/setup_operations.py @@ -2,39 +2,15 @@ from pathlib import Path -import yaml - from bcbench.config import get_config -from bcbench.dataset.dataset_entry import BaseDatasetEntry, _BugFixTestGenBase +from bcbench.dataset.dataset_entry import BaseDatasetEntry from bcbench.logger import get_logger -from bcbench.operations.git_operations import apply_patch, checkout_commit, clean_repo -from bcbench.operations.instruction_operations import copy_problem_statement_folder -from bcbench.types import EvaluationCategory +from bcbench.operations.git_operations import checkout_commit, clean_repo logger = get_logger(__name__) _config = get_config() -__all__ = ["_get_test_generation_input_mode", "setup_repo_postbuild", "setup_repo_prebuild"] - - -def _get_test_generation_input_mode() -> str: - """Read test-generation input mode from shared agent config. - - Returns: - str: The validated input mode: "gold-patch", "problem-statement", or "both" - - Raises: - ValueError: If the input mode is not one of the valid values - """ - config_file: Path = _config.paths.agent_share_dir / "config.yaml" - shared_config = yaml.safe_load(config_file.read_text()) - input_mode: str = shared_config.get("prompt", {}).get("test-generation-input", "problem-statement") - - valid_modes: set[str] = {"gold-patch", "problem-statement", "both"} - if input_mode not in valid_modes: - raise ValueError(f"Invalid test-generation-input mode: '{input_mode}'. Must be one of {valid_modes}. Note: Use hyphens, not underscores (e.g., 'gold-patch' not 'gold_patch')") - - return input_mode +__all__ = ["setup_repo_prebuild"] def setup_repo_prebuild(entry: BaseDatasetEntry, repo_path: Path) -> None: @@ -42,36 +18,15 @@ def setup_repo_prebuild(entry: BaseDatasetEntry, repo_path: Path) -> None: This is the first phase of repo setup that should be called BEFORE build_and_publish_projects. It prepares a clean slate at the base commit without any patches or problem statements. + Skips for entries without a base_commit (e.g. categories that start from a blank project). Args: entry: Dataset entry with instance metadata repo_path: Path to the repository """ + if not entry.base_commit: + logger.info(f"Skipping prebuild setup for {entry.instance_id} (no base_commit)") + return + clean_repo(repo_path) checkout_commit(repo_path, entry.base_commit) - - -def setup_repo_postbuild(entry: _BugFixTestGenBase, repo_path: Path, category: EvaluationCategory) -> None: - """Setup repository after building for bug-fix and test-generation categories. - - This is the second phase of repo setup that should be called AFTER build_and_publish_projects. - For test-generation, this ensures the gold patch is applied only after the base code is built, - so the agent sees the fixed code but tests run against the unfixed published app. - - Note: Other categories should implement their own postbuild setup. - """ - if category == EvaluationCategory.TEST_GENERATION: - input_mode: str = _get_test_generation_input_mode() - logger.info(f"Test generation input mode: {input_mode}") - match input_mode: - case "gold-patch": - apply_patch(repo_path, entry.patch, f"{entry.instance_id} gold patch") - case "both": - apply_patch(repo_path, entry.patch, f"{entry.instance_id} gold patch") - copy_problem_statement_folder(entry, repo_path) - case "problem-statement": - copy_problem_statement_folder(entry, repo_path) - case _: - raise ValueError(f"Unhandled test generation input mode: {input_mode}") - else: - copy_problem_statement_folder(entry, repo_path) diff --git a/tests/test_testgeneration_validation.py b/tests/test_testgeneration_validation.py index 074d60de0..f29253ef6 100644 --- a/tests/test_testgeneration_validation.py +++ b/tests/test_testgeneration_validation.py @@ -3,7 +3,7 @@ import pytest import yaml -from bcbench.operations.setup_operations import _get_test_generation_input_mode +from bcbench.evaluate.testgeneration import _get_test_generation_input_mode def test_get_test_generation_input_mode_valid_gold_patch(): From 8e2f216fbc96ddb20550c6f43dde1db1cd2e723b Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Thu, 9 Apr 2026 10:46:35 +0200 Subject: [PATCH 03/37] enable skipping container setup in action --- .github/actions/setup-bc-container/action.yml | 8 +++- scripts/Setup-ContainerAndRepository.ps1 | 44 +++++++++++-------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/actions/setup-bc-container/action.yml b/.github/actions/setup-bc-container/action.yml index 700cc58c9..3b71c1457 100644 --- a/.github/actions/setup-bc-container/action.yml +++ b/.github/actions/setup-bc-container/action.yml @@ -14,6 +14,10 @@ inputs: github-token: description: GitHub token for accessing public repositories required: true + skip-container: + description: Skip BC container setup (only clone repository) + required: false + default: "false" outputs: repo_path: @@ -24,6 +28,7 @@ runs: using: composite steps: - name: Generate BC container name and credentials + if: inputs.skip-container != 'true' run: | # Generate a 32-character random password using Get-Random # The password is short-lived and only used for the duration of the workflow @@ -38,6 +43,7 @@ runs: shell: pwsh - name: Install BcContainerHelper module + if: inputs.skip-container != 'true' run: Install-Module -Name BcContainerHelper -Force -AllowClobber -AllowPrerelease shell: pwsh @@ -59,5 +65,5 @@ runs: $env:ADO_TOKEN = az account get-access-token --resource "499b84ac-1321-427f-aa17-267ca6975798" --query accessToken -o tsv Write-Output "::add-mask::$env:ADO_TOKEN" - .\scripts\Setup-ContainerAndRepository.ps1 -InstanceId "${{ inputs.instance-id }}" + .\scripts\Setup-ContainerAndRepository.ps1 -InstanceId "${{ inputs.instance-id }}" ${{ inputs.skip-container == 'true' && '-SkipContainer' || '' }} shell: pwsh diff --git a/scripts/Setup-ContainerAndRepository.ps1 b/scripts/Setup-ContainerAndRepository.ps1 index 36e665ed3..77f6d8d4b 100644 --- a/scripts/Setup-ContainerAndRepository.ps1 +++ b/scripts/Setup-ContainerAndRepository.ps1 @@ -25,7 +25,10 @@ param( [SecureString]$Password, [Parameter(Mandatory = $false)] - [string]$RepoPath + [string]$RepoPath, + + [Parameter(Mandatory = $false)] + [switch]$SkipContainer ) [DatasetEntry[]] $entries = Get-DatasetEntries -DatasetPath $DatasetPath -Version $Version -InstanceId $InstanceId @@ -37,9 +40,7 @@ else { Write-Log "Found $($entries.Count) dataset entries to process." -Level Info } -Write-Log "Setting up BC container and repository for version $Version, Dataset Path: $DatasetPath" -Level Info - -[PSCredential]$credential = Get-BCCredential -Username $Username -Password $Password +Write-Log "Setting up repository for version $Version, Dataset Path: $DatasetPath" -Level Info if (-not $RepoPath) { $RepoPath = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "testbed" @@ -56,27 +57,34 @@ if (Test-Path $RepoPath) { Write-Log "Cloning repository $($entries[0].repo) to $RepoPath" -Level Info Invoke-GitCloneWithRetry -RepoUrl $cloneInfo.Url -Token $cloneInfo.Token -ClonePath $RepoPath -CommitSha $commitSha -SparseCheckoutPaths $cloneInfo.SparseCheckoutPaths -Import-Module BcContainerHelper -Force -DisableNameChecking +if (-not $SkipContainer) { + [PSCredential]$credential = Get-BCCredential -Username $Username -Password $Password -Write-Log "Container name: $ContainerName" -Level Info + Import-Module BcContainerHelper -Force -DisableNameChecking -if (Test-ContainerExists -containerName $ContainerName) { - throw "Container $ContainerName already exists. This indicates the machine was not properly cleaned up from a previous run." -} + Write-Log "Container name: $ContainerName" -Level Info -Write-Log "Creating container $ContainerName for version $Version..." -Level Info + if (Test-ContainerExists -containerName $ContainerName) { + throw "Container $ContainerName already exists. This indicates the machine was not properly cleaned up from a previous run." + } -# Get BC artifact URL -[string] $url = Get-BCArtifactUrl -version $Version -Country $Country -Write-Log "Retrieved artifact URL: $url" -Level Info + Write-Log "Creating container $ContainerName for version $Version..." -Level Info -# Create container synchronously with NAV folder shared -New-BCContainerSync -ContainerName $ContainerName -Version $Version -ArtifactUrl $url -Credential $credential -AdditionalFolders @($RepoPath) + # Get BC artifact URL + [string] $url = Get-BCArtifactUrl -version $Version -Country $Country + Write-Log "Retrieved artifact URL: $url" -Level Info -# Create compiler folder synchronously -New-BCCompilerFolderSync -ContainerName $ContainerName -ArtifactUrl $url + # Create container synchronously with NAV folder shared + New-BCContainerSync -ContainerName $ContainerName -Version $Version -ArtifactUrl $url -Credential $credential -AdditionalFolders @($RepoPath) -Initialize-ContainerForDevelopment -ContainerName $ContainerName -RepoVersion ([System.Version]$Version) + # Create compiler folder synchronously + New-BCCompilerFolderSync -ContainerName $ContainerName -ArtifactUrl $url + + Initialize-ContainerForDevelopment -ContainerName $ContainerName -RepoVersion ([System.Version]$Version) +} +else { + Write-Log "Skipping BC container setup (SkipContainer flag set)" -Level Info +} # Set output for GitHub Actions or return path if ($env:GITHUB_OUTPUT) { From 69a8db866ebb622be40446c2e97031a5d4a2bcf0 Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Thu, 9 Apr 2026 11:17:19 +0200 Subject: [PATCH 04/37] fix missing implementation for MockEvaluationPipeline --- src/bcbench/commands/evaluate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/bcbench/commands/evaluate.py b/src/bcbench/commands/evaluate.py index ffef5d745..c0535c5a3 100644 --- a/src/bcbench/commands/evaluate.py +++ b/src/bcbench/commands/evaluate.py @@ -228,6 +228,9 @@ class MockEvaluationPipeline(EvaluationPipeline[BaseDatasetEntry]): It randomly generates different scenarios to test result handling and serialization. """ + def setup_workspace(self, entry: BaseDatasetEntry, repo_path: Path) -> None: + logger.info("Mock pipeline: Skipping workspace setup") + def setup(self, context: EvaluationContext[BaseDatasetEntry]) -> None: logger.info("Mock pipeline: Skipping setup") From 7549d922ce1702f6b155f292a4ef9df584a5930c Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Sat, 11 Apr 2026 17:02:25 +0200 Subject: [PATCH 05/37] Refactor evaluation result classes to be more generic --- src/bcbench/commands/evaluate.py | 8 +- src/bcbench/commands/result.py | 28 +- src/bcbench/results/__init__.py | 10 +- src/bcbench/results/base.py | 116 +++--- src/bcbench/results/bceval_export.py | 10 +- src/bcbench/results/bugfix.py | 10 +- src/bcbench/results/display.py | 70 +++- .../{evaluation_result.py => summary.py} | 191 +++++---- src/bcbench/results/testgeneration.py | 25 +- src/bcbench/types.py | 28 ++ tests/conftest.py | 8 +- tests/test_cli_commands.py | 50 ++- tests/test_evaluation_summary.py | 69 ++- tests/test_result_hierarchy.py | 393 ++++++++++++++++++ tests/test_result_serialization.py | 38 +- tests/test_version.py | 2 +- 16 files changed, 782 insertions(+), 274 deletions(-) rename src/bcbench/results/{evaluation_result.py => summary.py} (59%) create mode 100644 tests/test_result_hierarchy.py diff --git a/src/bcbench/commands/evaluate.py b/src/bcbench/commands/evaluate.py index c0535c5a3..1322ac473 100644 --- a/src/bcbench/commands/evaluate.py +++ b/src/bcbench/commands/evaluate.py @@ -23,7 +23,7 @@ from bcbench.dataset import BaseDatasetEntry from bcbench.evaluate import EvaluationPipeline from bcbench.logger import get_logger -from bcbench.results import BaseEvaluationResult +from bcbench.results import BaseEvaluationResult, ExecutionBasedEvaluationResult from bcbench.types import AgentMetrics, ContainerConfig, EvaluationContext, ExperimentConfiguration logger = get_logger(__name__) @@ -274,11 +274,11 @@ def evaluate(self, context: EvaluationContext[BaseDatasetEntry]) -> None: result: BaseEvaluationResult match scenario: case "success": - result = BaseEvaluationResult.create_success(context, "MOCK_PATCH_CONTENT") + result = ExecutionBasedEvaluationResult.create_success(context, "MOCK_PATCH_CONTENT") case "build-fail": - result = BaseEvaluationResult.create_build_failure(context, "MOCK_PATCH_CONTENT", "Mock build failure") + result = ExecutionBasedEvaluationResult.create_build_failure(context, "MOCK_PATCH_CONTENT", "Mock build failure") case "test-fail": - result = BaseEvaluationResult.create_test_failure(context, "MOCK_PATCH_CONTENT", "Mock test failure") + result = ExecutionBasedEvaluationResult.create_test_failure(context, "MOCK_PATCH_CONTENT", "Mock test failure") case _: raise ValueError("Invalid mock scenario, this should not happen") diff --git a/src/bcbench/commands/result.py b/src/bcbench/commands/result.py index 40cb6d375..178dd2749 100644 --- a/src/bcbench/commands/result.py +++ b/src/bcbench/commands/result.py @@ -12,11 +12,11 @@ from bcbench.results import ( BaseEvaluationResult, EvaluationResultSummary, + ExecutionBasedEvaluationResultSummary, Leaderboard, LeaderboardAggregate, create_console_summary, create_github_job_summary, - create_result_from_json, write_bceval_results, ) @@ -65,7 +65,7 @@ def result_summarize( for results_path in result_files: logger.info(f"Reading results from: {results_path}") with open(results_path) as f: - results.extend(create_result_from_json(json.loads(line)) for line in f if line.strip()) + results.extend(BaseEvaluationResult.from_json(json.loads(line)) for line in f if line.strip()) if not results: logger.error("No results found in the result files") @@ -73,13 +73,13 @@ def result_summarize( write_bceval_results(results, run_dir, run_id, bceval_output, category) + summary = EvaluationResultSummary.from_results(results, run_id=run_id) + if _config.env.github_actions: - create_github_job_summary(results) + create_github_job_summary(results, summary) else: - create_console_summary(results) + create_console_summary(results, summary) - # Save summary JSON - summary = EvaluationResultSummary.from_results(results, run_id=run_id) summary.save(run_dir, summary_output) @@ -90,8 +90,8 @@ def _get_combination_key(result: EvaluationResultSummary) -> tuple[str, str, str return (result.agent_name, result.model, exp_key, result.benchmark_version) -def _rebuild_aggregates(runs: list[EvaluationResultSummary]) -> list[LeaderboardAggregate]: - grouped: defaultdict[tuple[str, str, str | None, str], list[EvaluationResultSummary]] = defaultdict(list) +def _rebuild_aggregates(runs: list[ExecutionBasedEvaluationResultSummary]) -> list[LeaderboardAggregate]: + grouped: defaultdict[tuple[str, str, str | None, str], list[ExecutionBasedEvaluationResultSummary]] = defaultdict(list) for run in runs: grouped[_get_combination_key(run)].append(run) return [LeaderboardAggregate.from_runs(group) for group in grouped.values()] @@ -111,7 +111,7 @@ def result_update( """ logger.info(f"Loading evaluation summary from: {evaluation_summary}") with open(evaluation_summary, encoding="utf-8") as f: - new_result = EvaluationResultSummary.model_validate_json(f.read()) + new_result = ExecutionBasedEvaluationResultSummary.model_validate_json(f.read()) logger.info(f"Processing result for agent '{new_result.agent_name}' with model '{new_result.model}' in category '{new_result.category.value}'") @@ -120,13 +120,13 @@ def result_update( # Load existing leaderboard leaderboard: Leaderboard = Leaderboard.load(leaderboard_path) - runs: list[EvaluationResultSummary] = list(leaderboard.runs) + runs: list[ExecutionBasedEvaluationResultSummary] = list(leaderboard.runs) logger.info(f"Loaded {len(runs)} existing runs") # Find runs matching this combination new_result_key = _get_combination_key(new_result) - matching_runs: list[EvaluationResultSummary] = [r for r in runs if _get_combination_key(r) == new_result_key] - other_runs: list[EvaluationResultSummary] = [r for r in runs if _get_combination_key(r) != new_result_key] + matching_runs: list[ExecutionBasedEvaluationResultSummary] = [r for r in runs if _get_combination_key(r) == new_result_key] + other_runs: list[ExecutionBasedEvaluationResultSummary] = [r for r in runs if _get_combination_key(r) != new_result_key] if len(matching_runs) < n: logger.info(f"Adding run ({len(matching_runs) + 1}/{n}) for '{new_result.agent_name}' + '{new_result.model}'") @@ -137,7 +137,7 @@ def result_update( matching_runs = [*matching_runs[1:], new_result] # Combine and rebuild aggregates - all_runs: list[EvaluationResultSummary] = other_runs + matching_runs + all_runs: list[ExecutionBasedEvaluationResultSummary] = other_runs + matching_runs aggregates = _rebuild_aggregates(all_runs) # Write back @@ -171,7 +171,7 @@ def result_refresh( logger.info(f"Refreshing: {leaderboard_path.name}") leaderboard: Leaderboard = Leaderboard.load(leaderboard_path) - runs: list[EvaluationResultSummary] = list(leaderboard.runs) + runs: list[ExecutionBasedEvaluationResultSummary] = list(leaderboard.runs) if not runs: logger.warning(f"No runs found in {leaderboard_path.name}, skipping") diff --git a/src/bcbench/results/__init__.py b/src/bcbench/results/__init__.py index f769c58af..162f2f678 100644 --- a/src/bcbench/results/__init__.py +++ b/src/bcbench/results/__init__.py @@ -1,23 +1,25 @@ -from bcbench.results.base import create_result_from_json +from bcbench.results.base import ExecutionBasedEvaluationResult from bcbench.results.bceval_export import write_bceval_results from bcbench.results.display import create_console_summary, create_github_job_summary -from bcbench.results.evaluation_result import ( +from bcbench.results.metrics import bootstrap_ci, pass_at_k, pass_hat_k +from bcbench.results.summary import ( BaseEvaluationResult, EvaluationResultSummary, + ExecutionBasedEvaluationResultSummary, Leaderboard, LeaderboardAggregate, ) -from bcbench.results.metrics import bootstrap_ci, pass_at_k, pass_hat_k __all__ = [ "BaseEvaluationResult", "EvaluationResultSummary", + "ExecutionBasedEvaluationResult", + "ExecutionBasedEvaluationResultSummary", "Leaderboard", "LeaderboardAggregate", "bootstrap_ci", "create_console_summary", "create_github_job_summary", - "create_result_from_json", "pass_at_k", "pass_hat_k", "write_bceval_results", diff --git a/src/bcbench/results/base.py b/src/bcbench/results/base.py index fffe9beba..495eaaf27 100644 --- a/src/bcbench/results/base.py +++ b/src/bcbench/results/base.py @@ -16,16 +16,14 @@ class BaseEvaluationResult(BaseModel): """Base class for all evaluation results with shared metrics across categories.""" instance_id: str - project: str # TODO: move to category-specific subclasses? + project: str model: str agent_name: str category: EvaluationCategory - resolved: bool - build: bool timeout: bool = False - generated_patch: str = "" + output: str = "" error_message: str | None = None metrics: AgentMetrics | None = None @@ -35,26 +33,10 @@ class BaseEvaluationResult(BaseModel): def _create_from_context( cls, context: "EvaluationContext", - resolved: bool, - build: bool, error_message: str | None = None, - generated_patch: str = "", + output: str = "", **kwargs: Any, ) -> Self: - """Create result from EvaluationContext with validation and metric extraction. - - Args: - context: Evaluation context with configuration - resolved: Whether the evaluation was successful - build: Whether the build succeeded - error_message: Optional error message if evaluation failed - generated_patch: The generated patch content - **kwargs: Additional category-specific fields - - Returns: - Result instance (base or category-specific subclass) - """ - # Warn about missing metrics if they are not present if not context.metrics: logger.warning(f"Creating result for {context.entry.instance_id} with no agent metrics - performance data will be unavailable") elif missing_metrics := [name for name in AgentMetrics.model_fields if getattr(context.metrics, name) is None]: @@ -64,64 +46,86 @@ def _create_from_context( return cls( instance_id=context.entry.instance_id, project=project, - resolved=resolved, - build=build, model=context.model.replace(".", "-"), category=context.category, agent_name=context.agent_name, - generated_patch=generated_patch, + output=output, error_message=error_message, metrics=context.metrics, experiment=context.experiment, **kwargs, ) - @classmethod - def create_success(cls, context: "EvaluationContext", generated_patch: str, **kwargs: Any) -> Self: - return cls._create_from_context(context, resolved=True, build=True, generated_patch=generated_patch, **kwargs) - - @classmethod - def create_build_failure(cls, context: "EvaluationContext", generated_patch: str, error_msg: str, **kwargs: Any) -> Self: - return cls._create_from_context(context, resolved=False, build=False, error_message=error_msg, generated_patch=generated_patch, **kwargs) - - @classmethod - def create_test_failure(cls, context: "EvaluationContext", generated_patch: str, error_msg: str = "Tests failed", **kwargs: Any) -> Self: - return cls._create_from_context(context, resolved=False, build=True, error_message=error_msg, generated_patch=generated_patch, **kwargs) - @classmethod def create_agent_timeout_failure(cls, context: "EvaluationContext", **kwargs: Any) -> Self: - return cls._create_from_context(context, resolved=False, build=False, timeout=True, error_message="Agent timed out", **kwargs) + return cls._create_from_context(context, timeout=True, error_message="Agent timed out", **kwargs) def save(self, output_dir: Path, result_file: str) -> None: output_file = output_dir / result_file with open(output_file, "a", encoding="utf-8") as f: result_dict = self.model_dump(mode="json") - result_dict["category"] = self.category.value # Per-instance JSONL result files are uploaded as workflow artifacts and are the only inputs required by the summarize-results workflow. f.write(json.dumps(result_dict) + "\n") logger.info(f"Saved evaluation result for {self.instance_id} to {output_file}") + @property + def status_label(self) -> str: + """Short human-readable label for the result status shown in tables (e.g. 'Completed', 'Timeout').""" + if self.timeout: + return "Timeout" + if self.error_message: + return "Error" + return "Completed" + + @property + def category_metrics(self) -> dict[str, int | float | bool]: + """Category-specific metrics included in bceval export metadata. + + Keys become metadata fields; values must be JSON-serializable scalars. + Subclasses override to add metrics like 'resolved', 'build', etc. + """ + return {} -def create_result_from_json(payload: dict[str, Any]) -> BaseEvaluationResult: - """Create appropriate result instance from JSON payload based on category. + @property + def display_row(self) -> dict[str, str]: + """Extra columns for per-instance detail tables. - Args: - payload: Dictionary containing result data + Keys are column headers; values are the cell text for this result. + Subclasses override to surface category-specific per-instance info. + """ + return {} - Returns: - BugFixResult or TestGenerationResult instance based on category - """ - # Import here to avoid circular dependencies - from bcbench.results.bugfix import BugFixResult - from bcbench.results.testgeneration import TestGenerationResult + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "BaseEvaluationResult": + category = EvaluationCategory(payload["category"]) + return category.result_class.model_validate(payload) + + +class ExecutionBasedEvaluationResult(BaseEvaluationResult): + """Result for categories that involve building/compiling AL code and have binary pass/fail outcomes.""" + + resolved: bool = False + build: bool = False - category = EvaluationCategory(payload["category"]) + @classmethod + def create_success(cls, context: "EvaluationContext", output: str, **kwargs: Any) -> Self: + return cls._create_from_context(context, output=output, resolved=True, build=True, **kwargs) + + @classmethod + def create_build_failure(cls, context: "EvaluationContext", output: str, error_msg: str, **kwargs: Any) -> Self: + return cls._create_from_context(context, output=output, error_message=error_msg, resolved=False, build=False, **kwargs) - match category: - case EvaluationCategory.BUG_FIX: - return BugFixResult.model_validate(payload) - case EvaluationCategory.TEST_GENERATION: - return TestGenerationResult.model_validate(payload) - case _: - raise ValueError(f"Unknown evaluation category: {category}") + @classmethod + def create_test_failure(cls, context: "EvaluationContext", output: str, error_msg: str = "Tests failed", **kwargs: Any) -> Self: + return cls._create_from_context(context, output=output, error_message=error_msg, resolved=False, build=True, **kwargs) + + @property + def status_label(self) -> str: + if self.timeout: + return "Timeout" + return "Success" if self.resolved else "Failed" + + @property + def category_metrics(self) -> dict[str, int | float | bool]: + return {"resolved": self.resolved, "build": self.build} diff --git a/src/bcbench/results/bceval_export.py b/src/bcbench/results/bceval_export.py index eadb79fb0..ad28f4727 100644 --- a/src/bcbench/results/bceval_export.py +++ b/src/bcbench/results/bceval_export.py @@ -9,7 +9,6 @@ from bcbench.dataset import BaseDatasetEntry from bcbench.logger import get_logger from bcbench.results.base import BaseEvaluationResult -from bcbench.results.testgeneration import TestGenerationResult from bcbench.types import EvaluationCategory logger = get_logger(__name__) @@ -39,23 +38,18 @@ def write_bceval_results(results: list[BaseEvaluationResult], out_dir: Path, run "llm_duration": (result.metrics.llm_duration if result.metrics else None) or 0, "latency": (result.metrics.execution_time if result.metrics else None) or 0, "turn_count": (result.metrics.turn_count if result.metrics else None) or 0, - "resolved": result.resolved, - "build": result.build, + **result.category_metrics, "run_id": run_id, "project": result.project, "error_message": result.error_message, "tool_usage": (result.metrics.tool_usage if result.metrics and result.metrics.tool_usage else None) or 0, } - if isinstance(result, TestGenerationResult): - metadata["pre_patch_failed"] = result.pre_patch_failed - metadata["post_patch_passed"] = result.post_patch_passed - bceval_result = { "id": result.instance_id, "input": input, "expected": expected, - "output": result.generated_patch, + "output": result.output, "context": "", "metadata": metadata, "tags": [], diff --git a/src/bcbench/results/bugfix.py b/src/bcbench/results/bugfix.py index 505de5236..bc55dbe82 100644 --- a/src/bcbench/results/bugfix.py +++ b/src/bcbench/results/bugfix.py @@ -1,9 +1,5 @@ -from bcbench.results.base import BaseEvaluationResult +from bcbench.results.base import ExecutionBasedEvaluationResult -class BugFixResult(BaseEvaluationResult): - """Result class for bug-fix evaluation category. - - Inherits all shared metrics from BaseEvaluationResult. - Category-specific fields can be added here as needed. - """ +class BugFixResult(ExecutionBasedEvaluationResult): + """Result class for bug-fix evaluation category.""" diff --git a/src/bcbench/results/display.py b/src/bcbench/results/display.py index bec719bc7..f0354f3a2 100644 --- a/src/bcbench/results/display.py +++ b/src/bcbench/results/display.py @@ -1,30 +1,31 @@ +from collections.abc import Sequence + from rich.console import Console from rich.table import Table from bcbench.config import get_config from bcbench.logger import get_logger from bcbench.results.base import BaseEvaluationResult -from bcbench.results.evaluation_result import _calculate_average_tool_usage +from bcbench.results.summary import EvaluationResultSummary, calculate_average_tool_usage logger = get_logger(__name__) console = Console() -def create_console_summary(results: list[BaseEvaluationResult]) -> None: +def create_console_summary(results: Sequence[BaseEvaluationResult], summary: EvaluationResultSummary) -> None: total = len(results) - resolved = sum(r.resolved for r in results) - failed = total - resolved + 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]") - console.print(f"Resolved: [bold green]{resolved}[/bold green]") - console.print(f"Failed: [bold red]{failed}[/bold red]") + for key, value in display_metrics.items(): + console.print(f"{key.replace('_', ' ').title()}: [bold]{value}[/bold]") # 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] if tool_usages: - avg_usage = _calculate_average_tool_usage(tool_usages) + avg_usage = calculate_average_tool_usage(tool_usages) if avg_usage: console.print("\n[bold cyan]Average Tool Usage[/bold cyan]") sorted_tools = sorted(avg_usage.items(), key=lambda x: x[1], reverse=True) @@ -35,6 +36,12 @@ def create_console_summary(results: list[BaseEvaluationResult]) -> None: table.add_column("Instance ID", style="cyan", no_wrap=True) table.add_column("Project", style="magenta", no_wrap=True) table.add_column("Status", justify="center") + + # Dynamic columns from display_row() + extra_columns = list(results[0].display_row.keys()) if results else [] + for col_name in extra_columns: + table.add_column(col_name, style="yellow") + table.add_column("MCP Servers", style="yellow") table.add_column("Custom Instructions", style="yellow") table.add_column("Skills", style="yellow") @@ -42,12 +49,14 @@ def create_console_summary(results: list[BaseEvaluationResult]) -> None: table.add_column("Error Message", style="dim") for result in results: - status = "[green]Success[/green]" if result.resolved else "[red]Failed[/red]" + has_error = result.error_message is not None or result.timeout + status = f"[red]{result.status_label}[/red]" if has_error else f"[green]{result.status_label}[/green]" 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" - table.add_row(result.instance_id, result.project, status, mcp_servers, custom_instructions, skills, custom_agent, result.error_message or "") + extra_values = list(result.display_row.values()) + table.add_row(result.instance_id, result.project, status, *extra_values, mcp_servers, custom_instructions, skills, custom_agent, result.error_message or "") console.print(table) console.print() @@ -61,12 +70,12 @@ def _get_short_error_message(error_message: str | None) -> str: return first_line.replace("|", "\\|") -def create_github_job_summary(results: list[BaseEvaluationResult]) -> None: +def create_github_job_summary(results: Sequence[BaseEvaluationResult], summary: EvaluationResultSummary) -> None: total = len(results) - resolved = sum(r.resolved for r in results) - failed = total - resolved + display_metrics: dict[str, int | float | bool] = summary.display_summary() + errors = sum(1 for r in results if r.error_message or r.timeout) - success_icon = ":white_check_mark:" if failed == 0 else ":x:" + success_icon = ":white_check_mark:" if errors == 0 else ":x:" mcp_servers = ", ".join(results[0].experiment.mcp_servers) if results[0].experiment and results[0].experiment.mcp_servers else "None" custom_instructions = "Yes" if results[0].experiment and results[0].experiment.custom_instructions else "No" @@ -77,31 +86,50 @@ def create_github_job_summary(results: list[BaseEvaluationResult]) -> None: tool_usage_section = "" tool_usages = [r.metrics.tool_usage for r in results if r.metrics and r.metrics.tool_usage is not None] if tool_usages: - avg_usage = _calculate_average_tool_usage(tool_usages) + avg_usage = calculate_average_tool_usage(tool_usages) if avg_usage: sorted_tools = sorted(avg_usage.items(), key=lambda x: x[1], reverse=True) tool_lines = [f" - `{tool}`: {count}" for tool, count in sorted_tools] tool_usage_section = "\n\n## Average Tool Usage\n" + "\n".join(tool_lines) + # Build category-specific summary lines + display_lines = "\n".join(f"- {key.replace('_', ' ').title()}: {value}" for key, value in display_metrics.items()) + 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} - Custom Instructions: {custom_instructions} - Skills: {skills} - Custom Agent: {custom_agent} -- Successful evaluations: {resolved} :white_check_mark: -- Failed evaluations: {failed} {success_icon}{tool_usage_section} +{display_lines} +- Errors: {errors} {success_icon}{tool_usage_section} ## Detailed Results -| Instance ID | Project | Status | Error Message | -|-------------|---------|--------|---------------| """ + + # Dynamic columns from display_row() + extra_columns = list(results[0].display_row.keys()) if results else [] + extra_headers = " | ".join(extra_columns) + 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" + else: + markdown_summary += "| Instance ID | Project | Status | Error Message |\n" + markdown_summary += "|-------------|---------|--------|---------------|\n" + for result in results: - status_icon = ":white_check_mark:" if result.resolved else ":x:" - status_text = f"{status_icon} {'Success' if result.resolved else 'Failed'}" + has_error = result.error_message is not None or result.timeout + status_icon = ":x:" if has_error else ":white_check_mark:" + status_text = f"{status_icon} {result.status_label}" error_msg = _get_short_error_message(result.error_message) - markdown_summary += f"| `{result.instance_id}` | `{result.project}` | {status_text} | {error_msg} |\n" + 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" + else: + markdown_summary += f"| `{result.instance_id}` | `{result.project}` | {status_text} | {error_msg} |\n" _write_github_step_summary(markdown_summary) diff --git a/src/bcbench/results/evaluation_result.py b/src/bcbench/results/summary.py similarity index 59% rename from src/bcbench/results/evaluation_result.py rename to src/bcbench/results/summary.py index 79aae3e39..858b19acd 100644 --- a/src/bcbench/results/evaluation_result.py +++ b/src/bcbench/results/summary.py @@ -1,11 +1,13 @@ import json import tomllib +from abc import ABC, abstractmethod from collections import Counter +from collections.abc import Sequence from datetime import date from pathlib import Path -from typing import Any, Sequence +from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, Field from bcbench.logger import get_logger from bcbench.results.base import BaseEvaluationResult @@ -28,12 +30,14 @@ def _get_benchmark_version() -> str: return tomllib.load(f).get("project", {}).get("version", "unknown") -class EvaluationResultSummary(BaseModel): +class EvaluationResultSummary(BaseModel, ABC): + """Base summary for a single evaluation run across all instances. + + Contains agent metrics common to every category (tokens, duration, tool usage). + Category-specific metrics (resolved, build, etc.) live on subclasses. + """ + total: int - resolved: int - failed: int - build: int - percentage: float date: date @@ -50,39 +54,38 @@ class EvaluationResultSummary(BaseModel): github_run_id: str | None = None experiment: ExperimentConfiguration | None = None - # Per-instance results for aggregate metrics calculation: instance_id -> resolved - instance_results: dict[str, bool] | None = None - - # Benchmark version from pyproject.toml at evaluation time benchmark_version: str + @abstractmethod + def display_summary(self) -> dict[str, int | float]: + """Return category-specific metrics for console/GitHub summary display. + + Subclasses must override. Keys become display labels (underscores replaced + with spaces and title-cased). Values are shown as-is. + """ + @classmethod def from_results(cls, results: Sequence[BaseEvaluationResult], run_id: str) -> "EvaluationResultSummary": - total = len(results) - resolved = sum(r.resolved for r in results) + """Create a summary from a list of per-instance results. + + When called on the base class, dispatches to the correct subclass. + Subclasses override, call super().from_results(), and extend via model_copy(). + """ + if cls is EvaluationResultSummary: + summary_cls = results[0].category.summary_class + return summary_cls.from_results(results, run_id) durations = [r.metrics.execution_time for r in results if r.metrics and r.metrics.execution_time is not None] prompt_tokens = [r.metrics.prompt_tokens for r in results if r.metrics and r.metrics.prompt_tokens is not None] completion_tokens = [r.metrics.completion_tokens for r in results if r.metrics and r.metrics.completion_tokens is not None] llm_durations = [r.metrics.llm_duration for r in results if r.metrics and r.metrics.llm_duration is not None] - - # Calculate average tool usage across all results tool_usages = [r.metrics.tool_usage for r in results if r.metrics and r.metrics.tool_usage is not None] - average_tool_usage = _calculate_average_tool_usage(tool_usages) if tool_usages else None - # Extract experiment configuration from first result (all should be same in a run) first_result = results[0] experiment = first_result.experiment if first_result.experiment and not first_result.experiment.is_empty() else None - # Create per-instance results for aggregate metrics calculation - instance_results = {r.instance_id: r.resolved for r in results} - return cls( - total=total, - resolved=resolved, - percentage=round(resolved / total * 100, 1), - failed=total - resolved, - build=sum(r.build for r in results), + total=len(results), date=date.today(), category=first_result.category, model=first_result.model, @@ -91,16 +94,19 @@ def from_results(cls, results: Sequence[BaseEvaluationResult], run_id: str) -> " average_prompt_tokens=sum(prompt_tokens) / len(prompt_tokens) if prompt_tokens else 0.0, average_completion_tokens=sum(completion_tokens) / len(completion_tokens) if completion_tokens else 0.0, average_llm_duration=sum(llm_durations) / len(llm_durations) if llm_durations else 0.0, - average_tool_usage=average_tool_usage, + average_tool_usage=calculate_average_tool_usage(tool_usages) if tool_usages else None, github_run_id=run_id, experiment=experiment, - instance_results=instance_results, benchmark_version=_get_benchmark_version(), ) + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "EvaluationResultSummary": + category = EvaluationCategory(payload["category"]) + return category.summary_class.model_validate(payload) + def to_dict(self) -> dict[str, Any]: data = self.model_dump(mode="json") - # Round numeric values for readability data["average_duration"] = round(data["average_duration"], 1) data["average_prompt_tokens"] = round(data["average_prompt_tokens"], 1) data["average_completion_tokens"] = round(data["average_completion_tokens"], 1) @@ -115,15 +121,65 @@ def save(self, output_dir: Path, summary_file: str) -> None: logger.info(f"Saved evaluation summary to {output_file}") +class ExecutionBasedEvaluationResultSummary(EvaluationResultSummary): + """Summary for categories with binary pass/fail outcomes (bug-fix, test-generation). + + Fields match the original flat layout in the leaderboard JSON files. + """ + + resolved: int = 0 + failed: int = 0 + build: int = 0 + percentage: float = 0.0 + + # Per-instance pass/fail for aggregate metrics (pass^k, CI) + instance_results: dict[str, bool] = Field(default_factory=dict) + + def display_summary(self) -> dict[str, int | float]: + return { + "resolved": self.resolved, + "failed": self.failed, + "build": self.build, + "percentage": self.percentage, + } + + @classmethod + def from_results(cls, results: Sequence[BaseEvaluationResult], run_id: str) -> "ExecutionBasedEvaluationResultSummary": + from bcbench.results.base import ExecutionBasedEvaluationResult + + summary = super().from_results(results, run_id) + assert isinstance(summary, ExecutionBasedEvaluationResultSummary) + total = summary.total + + resolved = sum(1 for r in results if isinstance(r, ExecutionBasedEvaluationResult) and r.resolved) + build = sum(1 for r in results if isinstance(r, ExecutionBasedEvaluationResult) and r.build) + instance_results = {r.instance_id: (isinstance(r, ExecutionBasedEvaluationResult) and r.resolved) for r in results} + + return summary.model_copy( + update={ + "resolved": resolved, + "failed": total - resolved, + "build": build, + "percentage": round(resolved / total * 100, 1) if total else 0.0, + "instance_results": instance_results, + } + ) + + +# --------------------------------------------------------------------------- +# Leaderboard aggregation (execution-based categories only) +# --------------------------------------------------------------------------- + + class LeaderboardAggregate(BaseModel): + """Aggregate metrics across multiple runs. Execution-based categories only for now.""" + model: str agent_name: str category: EvaluationCategory experiment: ExperimentConfiguration | None = None - # Total instances in benchmark total: int - # Number of runs aggregated num_runs: int average: float | None = None @@ -131,68 +187,43 @@ class LeaderboardAggregate(BaseModel): ci_high: float | None = None pass_hat_5: float | None = None - # Averaged metrics across runs average_duration: float | None = None - # Benchmark version(s) from aggregated runs benchmark_version: str @classmethod - def from_runs(cls, runs: Sequence[EvaluationResultSummary]) -> "LeaderboardAggregate": + def from_runs(cls, runs: Sequence[ExecutionBasedEvaluationResultSummary]) -> "LeaderboardAggregate": if not runs: raise ValueError("Cannot create aggregate from empty runs list") - first_run: EvaluationResultSummary = runs[0] - total: int = first_run.total - num_runs: int = len(runs) - - # All runs should have the same benchmark_version (enforced by _get_combination_key grouping) - benchmark_version: str = first_run.benchmark_version + first_run = runs[0] + total = first_run.total + num_runs = len(runs) + benchmark_version = first_run.benchmark_version - # Warn if runs have different instance counts unique_totals = {r.total for r in runs} if len(unique_totals) > 1: logger.warning(f"Aggregating runs with different instance counts for '{first_run.agent_name}' + '{first_run.model}': {sorted(unique_totals)}. pass^k metrics may be misleading.") # Average duration across runs - durations: list[float] = [r.average_duration for r in runs if r.average_duration] - average_duration: float | None = sum(durations) / len(durations) if durations else None - - # Legacy single run without instance_results: use simple pass rate - if num_runs == 1 and not first_run.instance_results: - pass_rate = first_run.resolved / first_run.total if first_run.total > 0 else 0.0 - return cls( - model=first_run.model, - agent_name=first_run.agent_name, - category=first_run.category, - experiment=first_run.experiment, - total=total, - num_runs=num_runs, - average=round(pass_rate, 3), - ci_low=None, - ci_high=None, - pass_hat_5=None, - average_duration=round(average_duration, 1) if average_duration else None, - benchmark_version=benchmark_version, - ) + durations = [r.average_duration for r in runs if r.average_duration] + average_duration = sum(durations) / len(durations) if durations else None # Collect per-instance results across runs for pass^5 instance_resolved: dict[str, list[bool]] = {} for run in runs: - if run.instance_results: - for instance_id, resolved in run.instance_results.items(): - if instance_id not in instance_resolved: - instance_resolved[instance_id] = [] - instance_resolved[instance_id].append(resolved) - - # Calculate per-run pass rates for average and CI - per_run_rates = [run.resolved / run.total for run in runs if run.total > 0] + for instance_id, outcome in run.instance_results.items(): + if instance_id not in instance_resolved: + instance_resolved[instance_id] = [] + instance_resolved[instance_id].append(bool(outcome)) + + # Per-run scores for average and CI + per_run_rates = [run.percentage / 100.0 for run in runs] avg = round(sum(per_run_rates) / len(per_run_rates), 3) if per_run_rates else None ci_result = bootstrap_ci(per_run_rates) ci_low = round(ci_result["ci_low"], 3) if ci_result["ci_low"] is not None else None ci_high = round(ci_result["ci_high"], 3) if ci_result["ci_high"] is not None else None - # Calculate pass^5 pass_hat_5_val = _calculate_pass_hat_k(instance_resolved, 5, num_runs) if num_runs >= 5 else None return cls( @@ -212,7 +243,13 @@ def from_runs(cls, runs: Sequence[EvaluationResultSummary]) -> "LeaderboardAggre class Leaderboard(BaseModel): - runs: list[EvaluationResultSummary] + """Leaderboard for execution-based categories only. + + Non-execution-based categories (e.g. code-review) will need a different + leaderboard model once they are introduced. + """ + + runs: list[ExecutionBasedEvaluationResultSummary] aggregate: list[LeaderboardAggregate] @classmethod @@ -221,7 +258,6 @@ def load(cls, path: Path) -> "Leaderboard": return cls(runs=[], aggregate=[]) with open(path, encoding="utf-8") as f: data = json.load(f) - # Handle empty arrays or invalid structures if not data or not isinstance(data, dict): return cls(runs=[], aggregate=[]) return cls.model_validate(data) @@ -233,6 +269,11 @@ def to_dict(self) -> dict[str, Any]: } +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + def _calculate_pass_hat_k(instance_resolved: dict[str, list[bool]], k: int, num_trials: int) -> float: if num_trials < k: return 0.0 @@ -245,16 +286,10 @@ def _calculate_pass_hat_k(instance_resolved: dict[str, list[bool]], k: int, num_ return round(total_pass_hat_k / len(instance_resolved), 3) -def _calculate_average_tool_usage(tool_usages: list[dict[str, int]]) -> dict[str, float]: - """Calculate average tool usage across multiple results. - - Sums up all tool counts and divides by the number of results to get average. - """ +def calculate_average_tool_usage(tool_usages: list[dict[str, int]]) -> dict[str, float]: if not tool_usages: return {} aggregated = sum((Counter(usage) for usage in tool_usages), Counter()) - - # Calculate average (rounded to 2 decimal places) num_results = len(tool_usages) return {tool: round(count / num_results, 2) for tool, count in aggregated.items()} diff --git a/src/bcbench/results/testgeneration.py b/src/bcbench/results/testgeneration.py index fb80d0e05..0393f84ae 100644 --- a/src/bcbench/results/testgeneration.py +++ b/src/bcbench/results/testgeneration.py @@ -1,19 +1,26 @@ from typing import Self -from bcbench.results.base import BaseEvaluationResult +from bcbench.results.base import ExecutionBasedEvaluationResult from bcbench.types import EvaluationContext -class TestGenerationResult(BaseEvaluationResult): - """Result class for test-generation evaluation category. - - Inherits all shared metrics from BaseEvaluationResult. - Tracks whether generated tests failed before patch and passed after patch. - """ +class TestGenerationResult(ExecutionBasedEvaluationResult): + """Result class for test-generation evaluation category.""" pre_patch_failed: bool = False post_patch_passed: bool = False + @property + def category_metrics(self) -> dict[str, int | float | bool]: + return {**super().category_metrics, "pre_patch_failed": self.pre_patch_failed, "post_patch_passed": self.post_patch_passed} + + @property + def display_row(self) -> dict[str, str]: + return { + "Pre-Patch Failed": "Yes" if self.pre_patch_failed else "No", + "Post-Patch Passed": "Yes" if self.post_patch_passed else "No", + } + @classmethod - def create_no_tests_extracted(cls, context: "EvaluationContext", generated_patch: str, error_message: str) -> Self: - return cls._create_from_context(context, resolved=False, build=False, generated_patch=generated_patch, error_message=error_message) + def create_no_tests_extracted(cls, context: "EvaluationContext", output: str, error_message: str) -> Self: + return cls._create_from_context(context, resolved=False, build=False, output=output, error_message=error_message) diff --git a/src/bcbench/types.py b/src/bcbench/types.py index c2177a8a6..80731d2e7 100644 --- a/src/bcbench/types.py +++ b/src/bcbench/types.py @@ -14,6 +14,8 @@ if TYPE_CHECKING: from bcbench.dataset import BaseDatasetEntry from bcbench.evaluate.base import EvaluationPipeline + from bcbench.results.base import BaseEvaluationResult + from bcbench.results.summary import EvaluationResultSummary __all__ = ["AgentMetrics", "AgentType", "ContainerConfig", "EvaluationCategory", "EvaluationContext", "ExperimentConfiguration"] @@ -126,6 +128,32 @@ def entry_class(self) -> type[BaseDatasetEntry]: raise ValueError(f"Unknown evaluation category: {self}") + @property + def result_class(self) -> type[BaseEvaluationResult]: + from bcbench.results.bugfix import BugFixResult + from bcbench.results.testgeneration import TestGenerationResult + + match self: + case EvaluationCategory.BUG_FIX: + return BugFixResult + case EvaluationCategory.TEST_GENERATION: + return TestGenerationResult + + raise ValueError(f"Unknown evaluation category: {self}") + + @property + def summary_class(self) -> type[EvaluationResultSummary]: + """Returns the EvaluationResultSummary subclass for this category.""" + from bcbench.results.summary import ExecutionBasedEvaluationResultSummary + + match self: + case EvaluationCategory.BUG_FIX: + return ExecutionBasedEvaluationResultSummary + case EvaluationCategory.TEST_GENERATION: + return ExecutionBasedEvaluationResultSummary + + raise ValueError(f"Unknown evaluation category: {self}") + @property def pipeline(self) -> EvaluationPipeline: from bcbench.evaluate import BugFixPipeline, TestGenerationPipeline diff --git a/tests/conftest.py b/tests/conftest.py index 6c6ab4922..ce2dfaf34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -103,7 +103,7 @@ def create_bugfix_result( agent_name: str = "copilot-cli", resolved: bool = True, build: bool = True, - generated_patch: str = "diff --git a/test.al b/test.al\n+fixed", + output: str = "diff --git a/test.al b/test.al\n+fixed", error_message: str | None = None, metrics: AgentMetrics | None = None, ) -> BugFixResult: @@ -115,7 +115,7 @@ def create_bugfix_result( category=EvaluationCategory.BUG_FIX, resolved=resolved, build=build, - generated_patch=generated_patch, + output=output, error_message=error_message, metrics=metrics, ) @@ -128,7 +128,7 @@ def create_testgen_result( agent_name: str = "copilot-cli", resolved: bool = False, build: bool = True, - generated_patch: str = "diff --git a/test.al b/test.al\n+test", + output: str = "diff --git a/test.al b/test.al\n+test", error_message: str | None = None, metrics: AgentMetrics | None = None, pre_patch_failed: bool = False, @@ -142,7 +142,7 @@ def create_testgen_result( category=EvaluationCategory.TEST_GENERATION, resolved=resolved, build=build, - generated_patch=generated_patch, + output=output, error_message=error_message, metrics=metrics, pre_patch_failed=pre_patch_failed, diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 2290cff5c..12f678ecf 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -342,6 +342,7 @@ def sample_leaderboard_and_summary(tmp_path): "failed": 4, "build": 9, "percentage": 60.0, + "instance_results": copilot_instance_results, "date": "2025-01-10", "model": "gpt-4o", "category": "bug-fix", @@ -356,7 +357,6 @@ def sample_leaderboard_and_summary(tmp_path): "custom_instructions": True, "custom_agent": None, }, - "instance_results": copilot_instance_results, "benchmark_version": "0.1.0", }, { @@ -365,6 +365,7 @@ def sample_leaderboard_and_summary(tmp_path): "failed": 3, "build": 10, "percentage": 70.0, + "instance_results": mini_instance_results, "date": "2025-01-12", "model": "gpt-4o", "category": "bug-fix", @@ -379,7 +380,6 @@ def sample_leaderboard_and_summary(tmp_path): "custom_instructions": False, "custom_agent": None, }, - "instance_results": mini_instance_results, "benchmark_version": "0.1.0", }, ], @@ -430,6 +430,7 @@ def sample_leaderboard_and_summary(tmp_path): "failed": 5, "build": 8, "percentage": 50.0, + "instance_results": testgen_instance_results, "date": "2025-01-11", "model": "gpt-4-turbo", "category": "test-generation", @@ -444,7 +445,6 @@ def sample_leaderboard_and_summary(tmp_path): "custom_instructions": False, "custom_agent": None, }, - "instance_results": testgen_instance_results, "benchmark_version": "0.1.0", }, ], @@ -477,10 +477,11 @@ def sample_leaderboard_and_summary(tmp_path): new_summary = { "total": 10, - "resolved": 8, # Improved from 6 to 8 + "resolved": 8, "failed": 2, - "build": 10, # Improved from 9 to 10 + "build": 10, "percentage": 80.0, + "instance_results": new_summary_instance_results, "date": "2025-01-15", "model": "gpt-4o", "category": "bug-fix", @@ -495,7 +496,6 @@ def sample_leaderboard_and_summary(tmp_path): "custom_instructions": True, "custom_agent": None, }, - "instance_results": new_summary_instance_results, "benchmark_version": "0.1.0", } @@ -561,6 +561,7 @@ def test_result_update_adds_new_entry(sample_leaderboard_and_summary): "failed": 1, "build": 10, "percentage": 90.0, + "instance_results": new_agent_instance_results, "date": "2025-01-16", "model": "gpt-4o", "category": "test-generation", @@ -575,7 +576,6 @@ def test_result_update_adds_new_entry(sample_leaderboard_and_summary): "custom_instructions": False, "custom_agent": None, }, - "instance_results": new_agent_instance_results, "benchmark_version": "0.1.0", } @@ -629,6 +629,7 @@ def test_result_update_distinguishes_by_mcp_servers(sample_leaderboard_and_summa "failed": 3, "build": 9, "percentage": 70.0, + "instance_results": diff_mcp_instance_results, "date": "2025-01-17", "model": "gpt-4o", "category": "bug-fix", @@ -643,7 +644,6 @@ def test_result_update_distinguishes_by_mcp_servers(sample_leaderboard_and_summa "custom_instructions": False, # Different from existing True "custom_agent": None, }, - "instance_results": diff_mcp_instance_results, "benchmark_version": "0.1.0", } @@ -771,6 +771,7 @@ def test_result_update_stores_multiple_results_with_default_n(sample_leaderboard "failed": 2, "build": 10, "percentage": 80.0, + "instance_results": multi_results_instance, "date": "2025-01-15", "model": "gpt-4o", "category": "bug-fix", @@ -785,7 +786,6 @@ def test_result_update_stores_multiple_results_with_default_n(sample_leaderboard "custom_instructions": True, "custom_agent": None, }, - "instance_results": multi_results_instance, "benchmark_version": "0.1.0", } @@ -824,6 +824,7 @@ def test_result_update_replaces_oldest_when_exceeding_n(sample_leaderboard_and_s "failed": 3, "build": 9, "percentage": 70.0, + "instance_results": oldest_instance_results, "model": "gpt-4o", "category": "bug-fix", "agent_name": "copilot", @@ -836,7 +837,6 @@ def test_result_update_replaces_oldest_when_exceeding_n(sample_leaderboard_and_s "custom_instructions": True, "custom_agent": None, }, - "instance_results": oldest_instance_results, "benchmark_version": "0.1.0", } @@ -858,7 +858,16 @@ def test_result_update_replaces_oldest_when_exceeding_n(sample_leaderboard_and_s # Now add a 6th result - should replace oldest (2025-01-10) newest_instance_results = {f"test__inst_{i}": (i < 9) for i in range(10)} # 9 resolved - summary_new = {**base_summary, "date": "2025-01-20", "github_run_id": "run_sixth", "resolved": 9, "instance_results": newest_instance_results} + summary_new = { + **base_summary, + "date": "2025-01-20", + "github_run_id": "run_sixth", + "resolved": 9, + "failed": 1, + "build": 10, + "percentage": 90.0, + "instance_results": newest_instance_results, + } with open(summary_path, "w") as f: json.dump(summary_new, f, indent=2) @@ -927,17 +936,13 @@ def test_result_refresh_handles_empty_leaderboard(tmp_path): @pytest.mark.integration def test_result_refresh_handles_legacy_runs_without_instance_results(tmp_path): - """Test that refresh handles legacy runs that don't have instance_results.""" + """Test that refresh handles runs without instance_results.""" leaderboard_path = tmp_path / "bug-fix.json" legacy_data = { "runs": [ { "total": 10, - "resolved": 6, - "failed": 4, - "build": 9, - "percentage": 60.0, "date": "2025-01-10", "model": "gpt-4o", "category": "bug-fix", @@ -948,8 +953,11 @@ def test_result_refresh_handles_legacy_runs_without_instance_results(tmp_path): "average_llm_duration": 70.0, "github_run_id": "run_legacy", "experiment": None, - "instance_results": None, # Legacy: no instance_results "benchmark_version": "0.1.0", + "resolved": 6, + "failed": 4, + "build": 9, + "percentage": 60.0, }, ], "aggregate": [ @@ -996,6 +1004,7 @@ def test_result_refresh_separates_runs_by_benchmark_version(tmp_path): "failed": 4, "build": 10, "percentage": 60.0, + "instance_results": {f"test__inst_{i}": (i < 6) for i in range(10)}, "date": "2025-01-10", "model": "gpt-4o", "category": "bug-fix", @@ -1006,7 +1015,6 @@ def test_result_refresh_separates_runs_by_benchmark_version(tmp_path): "average_llm_duration": 70.0, "github_run_id": "run_v1", "experiment": None, - "instance_results": {f"test__inst_{i}": (i < 6) for i in range(10)}, "benchmark_version": "0.1.0", }, { @@ -1015,6 +1023,7 @@ def test_result_refresh_separates_runs_by_benchmark_version(tmp_path): "failed": 2, "build": 10, "percentage": 80.0, + "instance_results": {f"test__inst_{i}": (i < 8) for i in range(10)}, "date": "2025-01-15", "model": "gpt-4o", "category": "bug-fix", @@ -1025,7 +1034,6 @@ def test_result_refresh_separates_runs_by_benchmark_version(tmp_path): "average_llm_duration": 65.0, "github_run_id": "run_v2", "experiment": None, - "instance_results": {f"test__inst_{i}": (i < 8) for i in range(10)}, "benchmark_version": "0.2.0", }, ], @@ -1070,6 +1078,7 @@ def test_result_update_groups_by_benchmark_version(tmp_path): "failed": 5, "build": 10, "percentage": 50.0, + "instance_results": {f"test__inst_{i}": (i < 5) for i in range(10)}, "date": "2025-01-10", "model": "gpt-4o", "category": "bug-fix", @@ -1080,7 +1089,6 @@ def test_result_update_groups_by_benchmark_version(tmp_path): "average_llm_duration": 70.0, "github_run_id": "run_v1", "experiment": None, - "instance_results": {f"test__inst_{i}": (i < 5) for i in range(10)}, "benchmark_version": "0.1.0", }, ], @@ -1113,6 +1121,7 @@ def test_result_update_groups_by_benchmark_version(tmp_path): "failed": 3, "build": 10, "percentage": 70.0, + "instance_results": {f"test__inst_{i}": (i < 7) for i in range(10)}, "date": "2025-01-15", "model": "gpt-4o", "category": "bug-fix", @@ -1123,7 +1132,6 @@ def test_result_update_groups_by_benchmark_version(tmp_path): "average_llm_duration": 65.0, "github_run_id": "run_v2", "experiment": None, - "instance_results": {f"test__inst_{i}": (i < 7) for i in range(10)}, "benchmark_version": "0.2.0", } diff --git a/tests/test_evaluation_summary.py b/tests/test_evaluation_summary.py index a0c9586bf..7c3c97690 100644 --- a/tests/test_evaluation_summary.py +++ b/tests/test_evaluation_summary.py @@ -4,7 +4,7 @@ import pytest from bcbench.config import get_config -from bcbench.results.evaluation_result import EvaluationResultSummary +from bcbench.results.summary import EvaluationResultSummary, ExecutionBasedEvaluationResultSummary from bcbench.types import AgentMetrics, EvaluationCategory, ExperimentConfiguration from tests.conftest import create_bugfix_result, create_testgen_result @@ -13,7 +13,7 @@ class TestEvaluationResultSummary: def test_summary_save_creates_json_file(self, tmp_path): - summary = EvaluationResultSummary( + summary = ExecutionBasedEvaluationResultSummary( total=10, resolved=8, failed=2, @@ -43,6 +43,7 @@ def test_summary_save_creates_json_file(self, tmp_path): assert data["resolved"] == 8 assert data["failed"] == 2 assert data["build"] == 9 + assert data["instance_results"] == {} assert data["date"] == "2025-01-15" assert data["model"] == "gpt-4o" assert data["agent_name"] == "copilot-cli" @@ -51,7 +52,7 @@ def test_summary_save_creates_json_file(self, tmp_path): assert data["average_completion_tokens"] == 1200.0 def test_summary_save_with_custom_filename(self, tmp_path): - summary = EvaluationResultSummary( + summary = ExecutionBasedEvaluationResultSummary( total=5, resolved=4, failed=1, @@ -74,7 +75,7 @@ def test_summary_save_with_custom_filename(self, tmp_path): assert output_file.exists() def test_loading_existing_results(self): - from bcbench.results.evaluation_result import Leaderboard + from bcbench.results.summary import Leaderboard for category in EvaluationCategory: leaderboard_path = _config.paths.leaderboard_dir / f"{category.value}.json" @@ -87,7 +88,7 @@ def test_loading_existing_results(self): else: # Old format: array of items for item in data: - EvaluationResultSummary.model_validate(item) + ExecutionBasedEvaluationResultSummary.model_validate(item) class TestFromResults: @@ -255,7 +256,7 @@ def test_summary_with_experiment_configuration(self): custom_instructions=True, custom_agent="custom-bc-agent", ) - summary = EvaluationResultSummary( + summary = ExecutionBasedEvaluationResultSummary( total=5, resolved=3, failed=2, @@ -279,7 +280,7 @@ def test_summary_with_experiment_configuration(self): assert summary.experiment.custom_agent == "custom-bc-agent" def test_summary_without_experiment_configuration(self): - summary = EvaluationResultSummary( + summary = ExecutionBasedEvaluationResultSummary( total=5, resolved=3, failed=2, @@ -303,7 +304,7 @@ def test_summary_save_includes_experiment_in_json(self, tmp_path): mcp_servers=["pylance"], custom_instructions=True, ) - summary = EvaluationResultSummary( + summary = ExecutionBasedEvaluationResultSummary( total=10, resolved=8, failed=2, @@ -332,7 +333,7 @@ def test_summary_save_includes_experiment_in_json(self, tmp_path): assert data["experiment"]["custom_agent"] is None def test_summary_save_with_none_experiment(self, tmp_path): - summary = EvaluationResultSummary( + summary = ExecutionBasedEvaluationResultSummary( total=5, resolved=3, failed=2, @@ -465,16 +466,16 @@ def test_from_results_creates_instance_results(self): summary = EvaluationResultSummary.from_results(results, run_id="test_run") - assert summary.instance_results is not None - assert len(summary.instance_results) == 3 - assert summary.instance_results["test__1"] is True - assert summary.instance_results["test__2"] is False - assert summary.instance_results["test__3"] is True + instance_results = summary.instance_results + assert len(instance_results) == 3 + assert instance_results["test__1"] is True + assert instance_results["test__2"] is False + assert instance_results["test__3"] is True class TestLeaderboardAggregate: def test_from_single_run_calculates_average(self): - from bcbench.results.evaluation_result import LeaderboardAggregate + from bcbench.results.summary import LeaderboardAggregate summary = EvaluationResultSummary.from_results( [ @@ -496,7 +497,7 @@ def test_from_single_run_calculates_average(self): assert agg.pass_hat_5 is None # Not enough runs def test_from_multiple_runs_calculates_average_and_ci_bounds(self): - from bcbench.results.evaluation_result import LeaderboardAggregate + from bcbench.results.summary import LeaderboardAggregate run1 = EvaluationResultSummary.from_results( [ @@ -535,7 +536,7 @@ def test_from_multiple_runs_calculates_average_and_ci_bounds(self): assert agg.pass_hat_5 is None # Not enough runs def test_average_and_ci_bounds_with_varying_results(self): - from bcbench.results.evaluation_result import LeaderboardAggregate + from bcbench.results.summary import LeaderboardAggregate # Create 3 runs where: # - run1: 3/3 resolved (100%) @@ -579,7 +580,7 @@ def test_average_and_ci_bounds_with_varying_results(self): assert agg.pass_hat_5 is None # Not enough runs def test_consistent_results_have_zero_ci(self): - from bcbench.results.evaluation_result import LeaderboardAggregate + from bcbench.results.summary import LeaderboardAggregate # All instances pass all runs run1 = EvaluationResultSummary.from_results( @@ -615,7 +616,7 @@ def test_consistent_results_have_zero_ci(self): class TestLeaderboard: def test_aggregate_from_runs(self): - from bcbench.results.evaluation_result import LeaderboardAggregate + from bcbench.results.summary import LeaderboardAggregate run1 = EvaluationResultSummary.from_results( [ @@ -632,7 +633,7 @@ def test_aggregate_from_runs(self): assert agg.average == 0.5 def test_leaderboard_to_dict(self): - from bcbench.results.evaluation_result import Leaderboard, LeaderboardAggregate + from bcbench.results.summary import Leaderboard, LeaderboardAggregate run1 = EvaluationResultSummary.from_results( [create_bugfix_result(instance_id="test__1", resolved=True)], @@ -649,11 +650,10 @@ def test_leaderboard_to_dict(self): assert data["aggregate"][0]["average"] == 1.0 def test_aggregate_from_legacy_runs_without_instance_results(self): - """Test that a single legacy run without instance_results uses pass rate ratio.""" - from bcbench.results.evaluation_result import LeaderboardAggregate + """Test that a single run without instance_results uses pass rate from percentage.""" + from bcbench.results.summary import LeaderboardAggregate - # Create a summary without instance_results (simulates legacy data) - legacy_run = EvaluationResultSummary( + legacy_run = ExecutionBasedEvaluationResultSummary( total=10, resolved=6, failed=4, @@ -666,7 +666,6 @@ def test_aggregate_from_legacy_runs_without_instance_results(self): average_duration=100.0, average_prompt_tokens=1000.0, average_completion_tokens=500.0, - instance_results=None, # Legacy: no instance_results benchmark_version="0.1.0", ) @@ -674,14 +673,14 @@ def test_aggregate_from_legacy_runs_without_instance_results(self): assert agg.num_runs == 1 assert agg.total == 10 - # Should fall back to pass rate (resolved/total) from the run - assert agg.average == 0.6 # 6/10 = 0.6 + # Uses percentage / 100 as the run's pass rate + assert agg.average == 0.6 # 60.0% -> 0.6 assert agg.ci_low is None assert agg.ci_high is None assert agg.pass_hat_5 is None def test_aggregate_includes_benchmark_version_from_runs(self): - from bcbench.results.evaluation_result import LeaderboardAggregate + from bcbench.results.summary import LeaderboardAggregate run1 = EvaluationResultSummary.from_results( [create_bugfix_result(instance_id="test__1", resolved=True)], @@ -695,14 +694,15 @@ def test_aggregate_includes_benchmark_version_from_runs(self): assert agg.benchmark_version is not None def test_aggregate_allows_same_benchmark_versions(self): - from bcbench.results.evaluation_result import LeaderboardAggregate + from bcbench.results.summary import LeaderboardAggregate - run1 = EvaluationResultSummary( + 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", @@ -710,15 +710,15 @@ def test_aggregate_allows_same_benchmark_versions(self): average_duration=100.0, average_prompt_tokens=1000.0, average_completion_tokens=500.0, - instance_results={"test__1": True, "test__2": True, "test__3": False}, benchmark_version="0.1.0", ) - run2 = EvaluationResultSummary( + 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="gpt-4o", agent_name="copilot", @@ -726,7 +726,6 @@ def test_aggregate_allows_same_benchmark_versions(self): average_duration=100.0, average_prompt_tokens=1000.0, average_completion_tokens=500.0, - instance_results={"test__1": False, "test__2": True, "test__3": False}, benchmark_version="0.1.0", # Same version ) @@ -735,7 +734,7 @@ def test_aggregate_allows_same_benchmark_versions(self): assert agg.benchmark_version == "0.1.0" def test_load_empty_leaderboard_file(self, tmp_path): - from bcbench.results.evaluation_result import Leaderboard + from bcbench.results.summary import Leaderboard empty_file = tmp_path / "empty.json" empty_file.write_text("[]") @@ -746,7 +745,7 @@ def test_load_empty_leaderboard_file(self, tmp_path): assert leaderboard.aggregate == [] def test_load_empty_object_leaderboard_file(self, tmp_path): - from bcbench.results.evaluation_result import Leaderboard + from bcbench.results.summary import Leaderboard empty_file = tmp_path / "empty.json" empty_file.write_text("{}") diff --git a/tests/test_result_hierarchy.py b/tests/test_result_hierarchy.py new file mode 100644 index 000000000..8420a61a0 --- /dev/null +++ b/tests/test_result_hierarchy.py @@ -0,0 +1,393 @@ +"""Tests for the result and summary class hierarchies after the category refactor. + +Covers: +- BaseEvaluationResult vs ExecutionBasedEvaluationResult field separation +- status_label, category_metrics, display_row polymorphism +- from_json dispatch to correct subclass +- EvaluationResultSummary.from_results dispatch and super() chain +- ExecutionBasedEvaluationResultSummary category-specific aggregation +- display_summary on summaries +- display.py console/GitHub summary rendering +""" + +from datetime import date + +import pytest + +from bcbench.results.base import BaseEvaluationResult, ExecutionBasedEvaluationResult +from bcbench.results.bugfix import BugFixResult +from bcbench.results.display import create_console_summary, create_github_job_summary +from bcbench.results.summary import ( + EvaluationResultSummary, + ExecutionBasedEvaluationResultSummary, +) +from bcbench.results.testgeneration import TestGenerationResult +from bcbench.types import AgentMetrics, EvaluationCategory +from tests.conftest import create_bugfix_result, create_evaluation_context, create_testgen_result + + +def _make_config_with_summary(summary_path: str): + """Create a config mock with github_step_summary set.""" + from bcbench.config import get_config + + config = get_config() + # Return a shallow copy-like object that overrides env.github_step_summary + from unittest.mock import MagicMock + + mock = MagicMock(wraps=config) + mock.env.github_step_summary = summary_path + return mock + + +# --------------------------------------------------------------------------- +# BaseEvaluationResult +# --------------------------------------------------------------------------- + + +class TestBaseEvaluationResult: + def test_base_has_no_resolved_or_build(self): + assert "resolved" not in BaseEvaluationResult.model_fields + assert "build" not in BaseEvaluationResult.model_fields + + def test_execution_based_has_resolved_and_build(self): + assert "resolved" in ExecutionBasedEvaluationResult.model_fields + assert "build" in ExecutionBasedEvaluationResult.model_fields + + def test_bugfix_inherits_execution_based(self): + assert issubclass(BugFixResult, ExecutionBasedEvaluationResult) + + def test_testgen_inherits_execution_based(self): + assert issubclass(TestGenerationResult, ExecutionBasedEvaluationResult) + + +# --------------------------------------------------------------------------- +# status_label +# --------------------------------------------------------------------------- + + +class TestStatusLabel: + def test_base_completed(self): + result = create_bugfix_result(resolved=True) + assert result.status_label == "Success" + + def test_base_timeout(self): + result = create_bugfix_result(resolved=False, build=False) + result.timeout = True + assert result.status_label == "Timeout" + + def test_execution_based_success(self): + result = create_bugfix_result(resolved=True, build=True) + assert result.status_label == "Success" + + def test_execution_based_failed(self): + result = create_bugfix_result(resolved=False, build=True, error_message="Tests failed") + assert result.status_label == "Failed" + + +# --------------------------------------------------------------------------- +# category_metrics +# --------------------------------------------------------------------------- + + +class TestCategoryMetrics: + def test_bugfix_category_metrics(self): + result = create_bugfix_result(resolved=True, build=True) + assert result.category_metrics == {"resolved": True, "build": True} + + def test_bugfix_failed_category_metrics(self): + result = create_bugfix_result(resolved=False, build=False) + assert result.category_metrics == {"resolved": False, "build": False} + + def test_testgen_category_metrics_includes_extra_fields(self): + result = create_testgen_result(resolved=True, build=True, pre_patch_failed=True, post_patch_passed=True) + metrics = result.category_metrics + assert metrics["resolved"] is True + assert metrics["build"] is True + assert metrics["pre_patch_failed"] is True + assert metrics["post_patch_passed"] is True + + def test_testgen_category_metrics_defaults(self): + result = create_testgen_result() + metrics = result.category_metrics + assert metrics["pre_patch_failed"] is False + assert metrics["post_patch_passed"] is False + + +# --------------------------------------------------------------------------- +# display_row +# --------------------------------------------------------------------------- + + +class TestDisplayRow: + def test_bugfix_display_row_is_empty(self): + result = create_bugfix_result() + assert result.display_row == {} + + def test_testgen_display_row_has_columns(self): + result = create_testgen_result(pre_patch_failed=True, post_patch_passed=False) + row = result.display_row + assert row["Pre-Patch Failed"] == "Yes" + assert row["Post-Patch Passed"] == "No" + + def test_testgen_display_row_no_flags(self): + result = create_testgen_result(pre_patch_failed=False, post_patch_passed=False) + row = result.display_row + assert row["Pre-Patch Failed"] == "No" + assert row["Post-Patch Passed"] == "No" + + +# --------------------------------------------------------------------------- +# from_json dispatch +# --------------------------------------------------------------------------- + + +class TestFromJsonDispatch: + def test_from_json_returns_bugfix_result(self): + payload = create_bugfix_result().model_dump(mode="json") + loaded = BaseEvaluationResult.from_json(payload) + assert isinstance(loaded, BugFixResult) + + def test_from_json_returns_testgen_result(self): + payload = create_testgen_result(pre_patch_failed=True).model_dump(mode="json") + loaded = BaseEvaluationResult.from_json(payload) + assert isinstance(loaded, TestGenerationResult) + assert loaded.pre_patch_failed is True + + def test_from_json_preserves_all_fields(self): + original = create_bugfix_result( + instance_id="test__round-trip", + resolved=True, + build=True, + output="patch content", + error_message=None, + ) + loaded = BaseEvaluationResult.from_json(original.model_dump(mode="json")) + assert loaded.instance_id == original.instance_id + assert loaded.output == original.output + + def test_from_json_unknown_category_raises(self): + payload = create_bugfix_result().model_dump(mode="json") + payload["category"] = "nonexistent" + with pytest.raises(ValueError, match="nonexistent"): + BaseEvaluationResult.from_json(payload) + + +# --------------------------------------------------------------------------- +# create_agent_timeout_failure +# --------------------------------------------------------------------------- + + +class TestCreateAgentTimeout: + def test_timeout_sets_fields(self, tmp_path): + ctx = create_evaluation_context(tmp_path) + result = BugFixResult.create_agent_timeout_failure(ctx) + assert result.timeout is True + assert result.error_message == "Agent timed out" + assert result.status_label == "Timeout" + + +# --------------------------------------------------------------------------- +# EvaluationResultSummary.from_results — dispatch + super() chain +# --------------------------------------------------------------------------- + + +class TestSummaryFromResults: + def test_base_dispatches_to_execution_based_for_bugfix(self): + results = [create_bugfix_result(instance_id="test__1", resolved=True)] + summary = EvaluationResultSummary.from_results(results, run_id="run1") + assert isinstance(summary, ExecutionBasedEvaluationResultSummary) + + def test_base_dispatches_to_execution_based_for_testgen(self): + results = [create_testgen_result(instance_id="test__1")] + summary = EvaluationResultSummary.from_results(results, run_id="run1") + assert isinstance(summary, ExecutionBasedEvaluationResultSummary) + + def test_subclass_direct_call_also_works(self): + results = [create_bugfix_result(instance_id="test__1", resolved=True)] + summary = ExecutionBasedEvaluationResultSummary.from_results(results, run_id="run1") + assert isinstance(summary, ExecutionBasedEvaluationResultSummary) + assert summary.resolved == 1 + + def test_common_fields_computed(self): + results = [ + create_bugfix_result( + instance_id="test__1", + resolved=True, + metrics=AgentMetrics(execution_time=100.0, prompt_tokens=1000, completion_tokens=500), + ), + create_bugfix_result( + instance_id="test__2", + resolved=False, + metrics=AgentMetrics(execution_time=200.0, prompt_tokens=3000, completion_tokens=1500), + ), + ] + summary = EvaluationResultSummary.from_results(results, run_id="run1") + + assert summary.total == 2 + assert summary.model == "gpt-4o" + assert summary.agent_name == "copilot-cli" + assert summary.average_duration == pytest.approx(150.0) + assert summary.average_prompt_tokens == pytest.approx(2000.0) + assert summary.average_completion_tokens == pytest.approx(1000.0) + assert summary.date == date.today() + + def test_category_specific_fields_computed(self): + results = [ + create_bugfix_result(instance_id="test__1", resolved=True, build=True), + create_bugfix_result(instance_id="test__2", resolved=False, build=True), + create_bugfix_result(instance_id="test__3", resolved=False, build=False), + ] + summary = EvaluationResultSummary.from_results(results, run_id="run1") + + assert isinstance(summary, ExecutionBasedEvaluationResultSummary) + assert summary.resolved == 1 + assert summary.failed == 2 + assert summary.build == 2 + assert summary.percentage == pytest.approx(33.3) + + def test_instance_results_populated(self): + results = [ + create_bugfix_result(instance_id="test__a", resolved=True), + create_bugfix_result(instance_id="test__b", resolved=False), + ] + summary = EvaluationResultSummary.from_results(results, run_id="run1") + + assert isinstance(summary, ExecutionBasedEvaluationResultSummary) + assert summary.instance_results == {"test__a": True, "test__b": False} + + +# --------------------------------------------------------------------------- +# display_summary +# --------------------------------------------------------------------------- + + +class TestDisplaySummary: + def test_execution_based_display_summary(self): + summary = ExecutionBasedEvaluationResultSummary( + total=10, + resolved=7, + failed=3, + build=9, + percentage=70.0, + 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", + ) + display = summary.display_summary() + assert display == {"resolved": 7, "failed": 3, "build": 9, "percentage": 70.0} + + +# --------------------------------------------------------------------------- +# Summary from_json dispatch +# --------------------------------------------------------------------------- + + +class TestSummaryFromJson: + def test_from_json_returns_execution_based_for_bugfix(self): + payload = { + "total": 5, + "resolved": 3, + "failed": 2, + "build": 4, + "percentage": 60.0, + "date": "2025-01-15", + "model": "gpt-4o", + "category": "bug-fix", + "agent_name": "copilot", + "average_duration": 100.0, + "average_prompt_tokens": 1000.0, + "average_completion_tokens": 500.0, + "benchmark_version": "0.1.0", + } + summary = EvaluationResultSummary.from_json(payload) + assert isinstance(summary, ExecutionBasedEvaluationResultSummary) + assert summary.resolved == 3 + + def test_from_json_unknown_category_raises(self): + payload = { + "total": 5, + "date": "2025-01-15", + "model": "gpt-4o", + "category": "nonexistent", + "agent_name": "copilot", + "average_duration": 100.0, + "average_prompt_tokens": 1000.0, + "average_completion_tokens": 500.0, + "benchmark_version": "0.1.0", + } + with pytest.raises(ValueError, match="nonexistent"): + EvaluationResultSummary.from_json(payload) + + +# --------------------------------------------------------------------------- +# display.py — console and GitHub summary +# --------------------------------------------------------------------------- + + +class TestConsoleSummary: + def test_console_summary_renders(self, capsys): + results = [ + create_bugfix_result(instance_id="test__1", resolved=True), + create_bugfix_result(instance_id="test__2", resolved=False, error_message="Build failed"), + ] + create_console_summary(results, EvaluationResultSummary.from_results(results, run_id="")) + captured = capsys.readouterr() + assert "test__1" in captured.out + assert "test__2" in captured.out + assert "Evaluation Results Summary" in captured.out + + def test_console_summary_shows_testgen_data_values(self, capsys): + results = [ + create_testgen_result(instance_id="test__1", resolved=True, pre_patch_failed=True, post_patch_passed=True), + ] + create_console_summary(results, EvaluationResultSummary.from_results(results, run_id="")) + captured = capsys.readouterr() + # Rich truncates column headers, but data values "Yes" should appear + assert "Yes" in captured.out + assert "test__1" in captured.out + + +class TestGitHubJobSummary: + def test_github_summary_renders_markdown(self, tmp_path, monkeypatch): + summary_file = tmp_path / "summary.md" + monkeypatch.setattr("bcbench.results.display.get_config", lambda: _make_config_with_summary(str(summary_file))) + results = [ + create_bugfix_result(instance_id="test__1", resolved=True), + create_bugfix_result(instance_id="test__2", resolved=False, error_message="Build failed"), + ] + create_github_job_summary(results, EvaluationResultSummary.from_results(results, run_id="")) + content = summary_file.read_text() + assert "test__1" in content + assert "test__2" in content + assert "bug-fix" in content + + def test_github_summary_includes_testgen_columns(self, tmp_path, monkeypatch): + summary_file = tmp_path / "summary.md" + monkeypatch.setattr("bcbench.results.display.get_config", lambda: _make_config_with_summary(str(summary_file))) + results = [ + create_testgen_result(instance_id="test__1", resolved=True, pre_patch_failed=True, post_patch_passed=True), + ] + create_github_job_summary(results, EvaluationResultSummary.from_results(results, run_id="")) + content = summary_file.read_text() + assert "Pre-Patch Failed" in content + assert "Post-Patch Passed" in content + + def test_github_summary_includes_tool_usage(self, tmp_path, monkeypatch): + summary_file = tmp_path / "summary.md" + monkeypatch.setattr("bcbench.results.display.get_config", lambda: _make_config_with_summary(str(summary_file))) + results = [ + create_bugfix_result( + instance_id="test__1", + resolved=True, + metrics=AgentMetrics(execution_time=100.0, tool_usage={"bash": 5, "view": 3}), + ), + ] + create_github_job_summary(results, EvaluationResultSummary.from_results(results, run_id="")) + content = summary_file.read_text() + assert "Tool Usage" in content + assert "bash" in content diff --git a/tests/test_result_serialization.py b/tests/test_result_serialization.py index c8e5487b3..7644fc8a8 100644 --- a/tests/test_result_serialization.py +++ b/tests/test_result_serialization.py @@ -2,8 +2,8 @@ import pytest -from bcbench.results.base import create_result_from_json -from bcbench.results.evaluation_result import EvaluationResultSummary +from bcbench.results.base import BaseEvaluationResult +from bcbench.results.summary import EvaluationResultSummary from bcbench.types import AgentMetrics, EvaluationCategory, ExperimentConfiguration from tests.conftest import create_bugfix_result, create_testgen_result @@ -55,10 +55,10 @@ def test_bug_fix_category_loads_from_string(self): "category": "bug-fix", "resolved": True, "build": True, - "generated_patch": "patch", + "output": "patch", } - result = create_result_from_json(payload) + result = BaseEvaluationResult.from_json(payload) assert result.category == EvaluationCategory.BUG_FIX @@ -71,10 +71,10 @@ def test_test_generation_category_loads_from_string(self): "category": "test-generation", "resolved": False, "build": True, - "generated_patch": "test patch", + "output": "test patch", } - result = create_result_from_json(payload) + result = BaseEvaluationResult.from_json(payload) assert result.category == EvaluationCategory.TEST_GENERATION @@ -88,7 +88,7 @@ def test_round_trip_bug_fix(self, tmp_path): with open(tmp_path / "test.jsonl") as f: data = json.loads(f.readline()) - loaded = create_result_from_json(data) + loaded = BaseEvaluationResult.from_json(data) assert loaded.category == original.category assert loaded.category == EvaluationCategory.BUG_FIX @@ -103,7 +103,7 @@ def test_round_trip_test_generation(self, tmp_path): with open(tmp_path / "test.jsonl") as f: data = json.loads(f.readline()) - loaded = create_result_from_json(data) + loaded = BaseEvaluationResult.from_json(data) assert loaded.category == original.category assert loaded.category == EvaluationCategory.TEST_GENERATION @@ -136,7 +136,9 @@ def test_summary_category_loads_from_string(self): "benchmark_version": "0.1.0", } - summary = EvaluationResultSummary.model_validate(payload) + from bcbench.results.summary import EvaluationResultSummary + + summary = EvaluationResultSummary.from_json(payload) # Pydantic handles the enum conversion automatically assert summary.category == EvaluationCategory.TEST_GENERATION @@ -210,7 +212,7 @@ def test_tool_usage_loads_from_json(self): "category": "bug-fix", "resolved": True, "build": True, - "generated_patch": "patch", + "output": "patch", "metrics": { "execution_time": 100.0, "prompt_tokens": 5000, @@ -219,7 +221,7 @@ def test_tool_usage_loads_from_json(self): }, } - result = create_result_from_json(payload) + result = BaseEvaluationResult.from_json(payload) assert result.metrics is not None assert result.metrics.tool_usage is not None @@ -243,10 +245,22 @@ def test_tool_usage_round_trip(self, tmp_path): with open(tmp_path / "test.jsonl") as f: data = json.loads(f.readline()) - loaded = create_result_from_json(data) + loaded = BaseEvaluationResult.from_json(data) assert loaded.metrics is not None assert loaded.metrics.tool_usage is not None assert original.metrics is not None assert original.metrics.tool_usage is not None assert loaded.metrics.tool_usage == original.metrics.tool_usage + + def test_model_dump_json_serializes_category_as_string_value(self): + bug_fix = create_bugfix_result() + test_gen = create_testgen_result() + + bug_fix_dump = bug_fix.model_dump(mode="json") + test_gen_dump = test_gen.model_dump(mode="json") + + assert bug_fix_dump["category"] == "bug-fix" + assert test_gen_dump["category"] == "test-generation" + assert isinstance(bug_fix_dump["category"], str) + assert isinstance(test_gen_dump["category"], str) diff --git a/tests/test_version.py b/tests/test_version.py index 654f4ff41..5082fc4a7 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,6 +1,6 @@ """Tests for version utility.""" -from bcbench.results.evaluation_result import _get_benchmark_version +from bcbench.results.summary import _get_benchmark_version def test_get_benchmark_version_returns_string(): From a4089b99fbe9cde94efa7a2689c99f457838795b Mon Sep 17 00:00:00 2001 From: Sun Haoran Date: Sun, 12 Apr 2026 07:08:00 +0000 Subject: [PATCH 06/37] Improve readabilty of GitHub Action summary --- src/bcbench/results/display.py | 8 ++++---- src/bcbench/results/summary.py | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/bcbench/results/display.py b/src/bcbench/results/display.py index f0354f3a2..965ad9cbf 100644 --- a/src/bcbench/results/display.py +++ b/src/bcbench/results/display.py @@ -73,9 +73,6 @@ def _get_short_error_message(error_message: str | None) -> str: def create_github_job_summary(results: Sequence[BaseEvaluationResult], summary: EvaluationResultSummary) -> None: total = len(results) display_metrics: dict[str, int | float | bool] = summary.display_summary() - errors = sum(1 for r in results if r.error_message or r.timeout) - - success_icon = ":white_check_mark:" if errors == 0 else ":x:" mcp_servers = ", ".join(results[0].experiment.mcp_servers) if results[0].experiment and results[0].experiment.mcp_servers else "None" custom_instructions = "Yes" if results[0].experiment and results[0].experiment.custom_instructions else "No" @@ -101,8 +98,11 @@ def create_github_job_summary(results: Sequence[BaseEvaluationResult], summary: - Custom Instructions: {custom_instructions} - Skills: {skills} - Custom Agent: {custom_agent} + +## Result Summary {display_lines} -- Errors: {errors} {success_icon}{tool_usage_section} + +{tool_usage_section} ## Detailed Results diff --git a/src/bcbench/results/summary.py b/src/bcbench/results/summary.py index 858b19acd..c4b221bfb 100644 --- a/src/bcbench/results/summary.py +++ b/src/bcbench/results/summary.py @@ -140,7 +140,6 @@ def display_summary(self) -> dict[str, int | float]: "resolved": self.resolved, "failed": self.failed, "build": self.build, - "percentage": self.percentage, } @classmethod From 99af6b2e890011ab01da60949131a6bfd924b975 Mon Sep 17 00:00:00 2001 From: Sun Haoran Date: Sun, 12 Apr 2026 07:21:39 +0000 Subject: [PATCH 07/37] fix failing tests --- tests/test_result_hierarchy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_result_hierarchy.py b/tests/test_result_hierarchy.py index 8420a61a0..a0daecb2f 100644 --- a/tests/test_result_hierarchy.py +++ b/tests/test_result_hierarchy.py @@ -279,7 +279,7 @@ def test_execution_based_display_summary(self): benchmark_version="0.1.0", ) display = summary.display_summary() - assert display == {"resolved": 7, "failed": 3, "build": 9, "percentage": 70.0} + assert display == {"resolved": 7, "failed": 3, "build": 9} # --------------------------------------------------------------------------- From e1b0b935d9a126c8fd1a496eae0fb7fcd7e9e0ac Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Sun, 12 Apr 2026 10:51:18 +0200 Subject: [PATCH 08/37] Code Review POC --- .github/workflows/CI.yml | 2 +- .github/workflows/claude-evaluation.yml | 2 + .github/workflows/copilot-evaluation.yml | 2 + .github/workflows/mini-evaluation.yml | 2 + dataset/codereview.jsonl | 1 + docs/_data/code-review.json | 4 + src/bcbench/agent/copilot/metrics.py | 50 ++++++++---- src/bcbench/agent/shared/config.yaml | 8 ++ src/bcbench/dataset/__init__.py | 3 + src/bcbench/dataset/codereview.py | 34 +++++++++ src/bcbench/evaluate/__init__.py | 3 +- src/bcbench/evaluate/codereview.py | 75 ++++++++++++++++++ src/bcbench/results/__init__.py | 2 + src/bcbench/results/codereview.py | 41 ++++++++++ src/bcbench/results/summary.py | 10 +++ src/bcbench/types.py | 19 ++++- tests/conftest.py | 54 +++++++++++++ tests/test_codereview.py | 97 ++++++++++++++++++++++++ tests/test_copilot_metrics_parsing.py | 41 ++++++++++ tests/test_type_exhaustiveness.py | 19 ++++- 20 files changed, 444 insertions(+), 25 deletions(-) create mode 100644 dataset/codereview.jsonl create mode 100644 docs/_data/code-review.json create mode 100644 src/bcbench/dataset/codereview.py create mode 100644 src/bcbench/evaluate/codereview.py create mode 100644 src/bcbench/results/codereview.py create mode 100644 tests/test_codereview.py diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1eafcb1fc..c8f97f953 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -41,7 +41,7 @@ jobs: id: random shell: pwsh run: | - $categories = @("bug-fix", "test-generation") + $categories = @("bug-fix", "test-generation", "code-review") $selected = $categories | Get-Random echo "category=$selected" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/claude-evaluation.yml b/.github/workflows/claude-evaluation.yml index a4fee3bf5..cd7104656 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 @@ -90,6 +91,7 @@ jobs: azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} github-token: ${{ secrets.GITHUB_TOKEN }} + skip-container: ${{ inputs.category == 'code-review' }} - name: Setup Python with UV uses: ./.github/actions/setup-python-uv diff --git a/.github/workflows/copilot-evaluation.yml b/.github/workflows/copilot-evaluation.yml index f1a5b5476..6f540834a 100644 --- a/.github/workflows/copilot-evaluation.yml +++ b/.github/workflows/copilot-evaluation.yml @@ -31,6 +31,7 @@ on: options: - "bug-fix" - "test-generation" + - "code-review" test-run: description: "Indicate this is a test run (with few entries)" required: false @@ -98,6 +99,7 @@ jobs: azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} github-token: ${{ secrets.GITHUB_TOKEN }} + skip-container: ${{ inputs.category == 'code-review' }} - name: Setup Python with UV uses: ./.github/actions/setup-python-uv diff --git a/.github/workflows/mini-evaluation.yml b/.github/workflows/mini-evaluation.yml index e50a905cc..84440e432 100644 --- a/.github/workflows/mini-evaluation.yml +++ b/.github/workflows/mini-evaluation.yml @@ -20,6 +20,7 @@ on: options: - "bug-fix" - "test-generation" + - "code-review" test-run: description: "Indicate this is a test run (with few entries)" required: false @@ -71,6 +72,7 @@ jobs: azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} github-token: ${{ secrets.GITHUB_TOKEN }} + skip-container: ${{ inputs.category == 'code-review' }} - name: Setup Python with UV uses: ./.github/actions/setup-python-uv diff --git a/dataset/codereview.jsonl b/dataset/codereview.jsonl new file mode 100644 index 000000000..61bac29cd --- /dev/null +++ b/dataset/codereview.jsonl @@ -0,0 +1 @@ +{"metadata": {"area": "shopify"}, "repo": "microsoft/BCApps", "instance_id": "microsoft__BCApps-4699", "base_commit": "effc43e8f96bc2b06545bcf81b9579bd08542747", "created_at": "2025-09-05T11:48:36Z", "environment_setup_version": "27.0", "project_paths": ["src\\Apps\\W1\\Shopify\\App", "src\\Apps\\W1\\Shopify\\Test"], "patch": "diff --git a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al\nindex 4e6ffd2866..717c4f204b 100644\n--- a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al\n+++ b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al\n@@ -8,6 +8,7 @@ namespace Microsoft.Integration.Shopify;\n using Microsoft.Inventory.Item;\n using Microsoft.Foundation.UOM;\n using Microsoft.Purchases.Vendor;\n+using Microsoft.Finance.Currency;\n using Microsoft.Inventory.Item.Catalog;\n \n /// \n@@ -230,6 +231,7 @@ codeunit 30171 \"Shpfy Create Item\"\n ItemCategory: Record \"Item Category\";\n ItemVariant: Record \"Item Variant\";\n Vendor: Record Vendor;\n+ CurrencyExchangeRate: Record \"Currency Exchange Rate\";\n CurrentTemplateCode: Code[20];\n ItemNo: Code[20];\n Code: Text;\n@@ -258,10 +260,16 @@ codeunit 30171 \"Shpfy Create Item\"\n CreateItemUnitOfMeasure(ShopifyVariant, Item);\n \n if ShopifyVariant.\"Unit Cost\" <> 0 then\n- Item.Validate(\"Unit Cost\", ShopifyVariant.\"Unit Cost\");\n+ if Shop.\"Currency Code\" = '' then\n+ Item.Validate(\"Unit Cost\", ShopifyVariant.\"Unit Cost\")\n+ else\n+ Item.Validate(\"Unit Cost\", Round(CurrencyExchangeRate.ExchangeAmtFCYToLCY(WorkDate(), Shop.\"Currency Code\", ShopifyVariant.\"Unit Cost\", CurrencyExchangeRate.ExchangeRate(WorkDate(), Shop.\"Currency Code\"))));\n \n if ShopifyVariant.Price <> 0 then\n- Item.Validate(\"Unit Price\", ShopifyVariant.Price);\n+ if Shop.\"Currency Code\" = '' then\n+ Item.Validate(\"Unit Price\", ShopifyVariant.Price)\n+ else\n+ Item.Validate(\"Unit Price\", Round(CurrencyExchangeRate.ExchangeAmtFCYToLCY(WorkDate(), Shop.\"Currency Code\", ShopifyVariant.Price, CurrencyExchangeRate.ExchangeRate(WorkDate(), Shop.\"Currency Code\"))));\n \n if ShopifyProduct.\"Product Type\" <> '' then begin\n ItemCategory.SetFilter(Description, FilterMgt.CleanFilterValue(ShopifyProduct.\"Product Type\", MaxStrLen(ItemCategory.Description)));\n", "expected_comments": [{"file": "src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al", "line_start": 263, "line_end": 266, "body": "The currency conversion logic for Unit Cost and Unit Price is duplicated. Consider extracting a helper procedure to convert FCY to LCY to reduce repetition and improve maintainability.", "severity": "suggestion"}, {"file": "src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al", "line_start": 265, "body": "Round() is called without specifying a rounding precision. This defaults to the system precision which may not match the currency's amount rounding precision. Consider using the currency's Amount Rounding Precision.", "severity": "warning"}]} 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/src/bcbench/agent/copilot/metrics.py b/src/bcbench/agent/copilot/metrics.py index 652265932..f22d69d66 100644 --- a/src/bcbench/agent/copilot/metrics.py +++ b/src/bcbench/agent/copilot/metrics.py @@ -21,6 +21,14 @@ TURN_COUNT_PATTERN = re.compile(r"--- Start of group: Sending request to the AI model ---") +def _parse_token_count(s: str) -> int: + if s.endswith("m"): + return int(float(s[:-1]) * 1000000) + if s.endswith("k"): + return int(float(s[:-1]) * 1000) + return int(float(s)) + + def parse_session_log(log_path: Path) -> tuple[dict[str, int], int]: """Parse tool usage and step count from a single Copilot CLI log file. @@ -49,7 +57,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 at the end: + Expected output format (new, v1.0.2+): + Changes +17 -0 + Requests 0.33 Premium (1m 45s) + Tokens ↑ 317.5k • ↓ 4.3k • 255.0k (cached) + + Legacy output format: Total usage est: 0.33 Premium requests API time spent: 2m 10.145s Total session time: 2m 41.651s @@ -85,35 +98,40 @@ def parse_metrics(output_lines: Sequence[str], session_log_path: Path | None = N turn_count = None try: - # Parse LLM duration (API time) + # Parse LLM duration (API time) — legacy format llm_duration_match = re.search(r"API time spent:\s*(?:(\d+)m\s*)?(\d+(?:\.\d+)?)s", output_text) if llm_duration_match: minutes = int(llm_duration_match.group(1)) if llm_duration_match.group(1) else 0 seconds = float(llm_duration_match.group(2)) llm_duration = minutes * 60 + seconds - # Parse wall clock duration + # Parse wall clock duration — legacy format duration_match = re.search(r"Total session time:\s*(?:(\d+)m\s*)?(\d+(?:\.\d+)?)s", output_text) if duration_match: minutes = int(duration_match.group(1)) if duration_match.group(1) else 0 seconds = float(duration_match.group(2)) execution_time = minutes * 60 + seconds - # Token usage: "1.3m in, 11.6k out" + # New format: "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: + minutes = int(requests_match.group(1)) if requests_match.group(1) else 0 + seconds = float(requests_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: - input_str = usage_match.group(1) - output_str = usage_match.group(2) - - def parse_token_count(s: str) -> int: - if s.endswith("m"): - return int(float(s[:-1]) * 1000000) - if s.endswith("k"): - return int(float(s[:-1]) * 1000) - return int(float(s)) - - prompt_tokens = parse_token_count(input_str) - completion_tokens = parse_token_count(output_str) + 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)" + 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)) 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 tool_usage 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 2f271f326..8ee4a356a 100644 --- a/src/bcbench/agent/shared/config.yaml +++ b/src/bcbench/agent/shared/config.yaml @@ -54,6 +54,14 @@ prompt: {{task}} {% endif %} + code-review-template: | + /review Review the unstaged changes in this repository and provide feedback as a code reviewer. + + Output your review as a JSON array of comment objects with the following schema: + [{"file": "path/to/file.al", "line_start": 10, "line_end": 15, "body": "Your review comment", "severity": "one of: suggestion, warning, error"}] + + Save the JSON array to a file named "review.json" in the current directory. + # 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 diff --git a/src/bcbench/dataset/__init__.py b/src/bcbench/dataset/__init__.py index 4e6e205fa..e719bcf8b 100644 --- a/src/bcbench/dataset/__init__.py +++ b/src/bcbench/dataset/__init__.py @@ -1,10 +1,13 @@ """Dataset module for querying, validating and analyze dataset entries.""" +from bcbench.dataset.codereview import CodeReviewEntry, ReviewComment from bcbench.dataset.dataset_entry import BaseDatasetEntry, BugFixEntry, TestEntry, TestGenEntry __all__ = [ "BaseDatasetEntry", "BugFixEntry", + "CodeReviewEntry", + "ReviewComment", "TestEntry", "TestGenEntry", ] diff --git a/src/bcbench/dataset/codereview.py b/src/bcbench/dataset/codereview.py new file mode 100644 index 000000000..943505510 --- /dev/null +++ b/src/bcbench/dataset/codereview.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from bcbench.dataset.dataset_entry import BaseDatasetEntry + + +class ReviewComment(BaseModel): + model_config = ConfigDict(frozen=True) + + file: str + line_start: int + line_end: int | None = None + body: str + severity: str = "suggestion" + + 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.""" + + # TODO: Code Review team should review the schema and update as needed. This is just a starting point + expected_comments: list[ReviewComment] = Field(default_factory=list) + + 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..9c9ba104b --- /dev/null +++ b/src/bcbench/evaluate/codereview.py @@ -0,0 +1,75 @@ +import json +from collections.abc import Callable +from pathlib import Path + +from bcbench.dataset.codereview import CodeReviewEntry, ReviewComment +from bcbench.evaluate.base import EvaluationPipeline +from bcbench.logger import get_logger, github_log_group +from bcbench.operations import apply_patch, setup_repo_prebuild +from bcbench.results.codereview import CodeReviewResult +from bcbench.types import EvaluationContext + +logger = get_logger(__name__) + +REVIEW_OUTPUT_FILE = "review.json" + +__all__ = ["CodeReviewPipeline"] + + +def _parse_review_json(repo_path: Path) -> list[ReviewComment]: + """Parse review.json produced by the agent into ReviewComment objects. + + NOTE: This is a minimal parser for the POC. The owning team should make this more robust. + """ + review_path = repo_path / REVIEW_OUTPUT_FILE + if not review_path.exists(): + logger.warning(f"No {REVIEW_OUTPUT_FILE} found at {review_path}") + return [] + + try: + raw = json.loads(review_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + logger.warning(f"Failed to parse {review_path} as JSON") + return [] + + if not isinstance(raw, list): + logger.warning(f"Expected JSON array in {review_path}, got {type(raw).__name__}") + return [] + + comments: list[ReviewComment] = [] + for item in raw: + if not isinstance(item, dict): + continue + try: + comments.append(ReviewComment.model_validate(item)) + except Exception: + logger.debug(f"Skipping malformed comment: {item}") + return comments + + +class CodeReviewPipeline(EvaluationPipeline[CodeReviewEntry]): + """Pipeline for code-review evaluation category. + + Code review does not require a BC container — the agent reviews a patch + and produces review comments without building or running tests. + """ + + def setup_workspace(self, entry: CodeReviewEntry, repo_path: Path) -> None: + setup_repo_prebuild(entry, repo_path) + apply_patch(repo_path, entry.patch, f"{entry.instance_id} code-review patch") + + 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: + generated_comments: list[ReviewComment] = _parse_review_json(context.repo_path) + logger.info(f"Parsed {len(generated_comments)} comments from {REVIEW_OUTPUT_FILE}") + result = CodeReviewResult.create_success(context, generated_comments=generated_comments) + # TODO: Code Review team should implement the real evaluation logic and populate metrics in the result + for comment in generated_comments: + logger.debug(f" {comment}") + self.save_result(context, result) diff --git a/src/bcbench/results/__init__.py b/src/bcbench/results/__init__.py index 162f2f678..e7c6db058 100644 --- a/src/bcbench/results/__init__.py +++ b/src/bcbench/results/__init__.py @@ -4,6 +4,7 @@ from bcbench.results.metrics import bootstrap_ci, pass_at_k, pass_hat_k from bcbench.results.summary import ( BaseEvaluationResult, + CodeReviewResultSummary, EvaluationResultSummary, ExecutionBasedEvaluationResultSummary, Leaderboard, @@ -12,6 +13,7 @@ __all__ = [ "BaseEvaluationResult", + "CodeReviewResultSummary", "EvaluationResultSummary", "ExecutionBasedEvaluationResult", "ExecutionBasedEvaluationResultSummary", diff --git a/src/bcbench/results/codereview.py b/src/bcbench/results/codereview.py new file mode 100644 index 000000000..a9bda0d45 --- /dev/null +++ b/src/bcbench/results/codereview.py @@ -0,0 +1,41 @@ +from typing import Any, Self + +from pydantic import Field + +from bcbench.dataset.codereview import ReviewComment +from bcbench.results.base import BaseEvaluationResult +from bcbench.types import EvaluationContext + +__all__ = ["CodeReviewResult"] + + +class CodeReviewResult(BaseEvaluationResult): + """Result for the code-review category.""" + + generated_comments: list[ReviewComment] = Field(default_factory=list) + + @classmethod + def create_success( + cls, + context: "EvaluationContext", + generated_comments: list[ReviewComment], + **kwargs: Any, + ) -> Self: + return cls._create_from_context( + context, + output="", + generated_comments=generated_comments, + **kwargs, + ) + + @property + def category_metrics(self) -> dict[str, int | float | bool]: + return { + "generated_comment_count": len(self.generated_comments), + } + + @property + def display_row(self) -> dict[str, str]: + return { + "Comments": str(len(self.generated_comments)), + } diff --git a/src/bcbench/results/summary.py b/src/bcbench/results/summary.py index c4b221bfb..e7cac9c4f 100644 --- a/src/bcbench/results/summary.py +++ b/src/bcbench/results/summary.py @@ -165,6 +165,16 @@ def from_results(cls, results: Sequence[BaseEvaluationResult], run_id: str) -> " ) +class CodeReviewResultSummary(EvaluationResultSummary): + """Summary for the code-review category (POC). + + TODO: Add scoring metrics (precision, recall, F1) once evaluation logic is implemented. + """ + + def display_summary(self) -> dict[str, int | float]: + return {"total": self.total} + + # --------------------------------------------------------------------------- # Leaderboard aggregation (execution-based categories only) # --------------------------------------------------------------------------- diff --git a/src/bcbench/types.py b/src/bcbench/types.py index 80731d2e7..008103cf8 100644 --- a/src/bcbench/types.py +++ b/src/bcbench/types.py @@ -101,7 +101,7 @@ def get_target_dir(self, repo_path: Path) -> Path: class EvaluationCategory(str, Enum): BUG_FIX = "bug-fix" TEST_GENERATION = "test-generation" - # CODE_REVIEW = "code-review" + CODE_REVIEW = "code-review" # EVENT_REQUEST = "event-request" @property @@ -113,24 +113,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: @@ -138,31 +143,37 @@ 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.summary import ExecutionBasedEvaluationResultSummary + from bcbench.results.summary import CodeReviewResultSummary, ExecutionBasedEvaluationResultSummary match self: case EvaluationCategory.BUG_FIX: return ExecutionBasedEvaluationResultSummary case EvaluationCategory.TEST_GENERATION: return ExecutionBasedEvaluationResultSummary + case EvaluationCategory.CODE_REVIEW: + return CodeReviewResultSummary 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}") diff --git a/tests/conftest.py b/tests/conftest.py index ce2dfaf34..0b64ce050 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,10 @@ import pytest from bcbench.dataset import BugFixEntry, TestEntry +from bcbench.dataset.codereview import CodeReviewEntry, ReviewComment from bcbench.dataset.dataset_entry import _BugFixTestGenBase 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 @@ -150,6 +152,58 @@ 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, + 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="warning"), + ReviewComment(file="src/app.al", line_start=20, body="Consider that", severity="suggestion"), + ] + + 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, + expected_comments=expected_comments, + ) + + +def create_codereview_result( + instance_id: str = VALID_INSTANCE_ID, + project: str = "Shopify", + model: str = "gpt-4o", + agent_name: str = "copilot-cli", + generated_comments: list[ReviewComment] | None = None, + metrics: AgentMetrics | None = None, +) -> CodeReviewResult: + if generated_comments is None: + generated_comments = [ReviewComment(file="test.al", line_start=5, body="Good catch")] + + return CodeReviewResult( + instance_id=instance_id, + project=project, + model=model, + agent_name=agent_name, + category=EvaluationCategory.CODE_REVIEW, + generated_comments=generated_comments, + metrics=metrics, + ) + + def create_dataset_file(tmp_path: Path, entries: list[BugFixEntry] | None = None) -> Path: if entries is None: entries = [create_dataset_entry()] diff --git a/tests/test_codereview.py b/tests/test_codereview.py new file mode 100644 index 000000000..dbcb6ea69 --- /dev/null +++ b/tests/test_codereview.py @@ -0,0 +1,97 @@ +import json + +from bcbench.dataset import CodeReviewEntry +from bcbench.dataset.codereview import ReviewComment +from bcbench.results.base import BaseEvaluationResult +from bcbench.results.codereview import CodeReviewResult +from bcbench.types import EvaluationCategory +from tests.conftest import create_codereview_entry, create_codereview_result, create_evaluation_context + + +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="warning"), + ReviewComment(file="src/app.al", line_start=20, body="Consider that", severity="suggestion"), + ] + entry = create_codereview_entry(expected_comments=comments) + output = entry.get_expected_output() + assert "[warning] src/app.al:10: Fix this" in output + assert "[suggestion] 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): + comments = [ReviewComment(file="test.al", line_start=5, body="Good catch")] + original = create_codereview_result( + instance_id="codereview-round-trip", + generated_comments=comments, + ) + + 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": "", + } + + result = BaseEvaluationResult.from_json(payload) + assert result.category == EvaluationCategory.CODE_REVIEW + assert isinstance(result, CodeReviewResult) + + +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 diff --git a/tests/test_copilot_metrics_parsing.py b/tests/test_copilot_metrics_parsing.py index e90231956..f55027573 100644 --- a/tests/test_copilot_metrics_parsing.py +++ b/tests/test_copilot_metrics_parsing.py @@ -198,6 +198,47 @@ def test_parse_metrics_minimal_real_output(): assert result.completion_tokens == 1500 +def test_parse_metrics_new_format_full(): + output_lines = [ + "Changes +17 -0\n", + "Requests 0.33 Premium (1m 45s)\n", + "Tokens ↑ 317.5k • ↓ 4.3k • 255.0k (cached)\n", + ] + + result = parse_metrics(output_lines) + + assert result is not None + assert result.execution_time == 105.0 + assert result.prompt_tokens == 317500 + assert result.completion_tokens == 4300 + + +def test_parse_metrics_new_format_seconds_only(): + output_lines = [ + "Requests 1 Premium (45s)\n", + "Tokens ↑ 125.5k • ↓ 3.6k • 0 (cached)\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_new_format_tokens_with_m(): + output_lines = [ + "Tokens ↑ 1.3m • ↓ 11.6k • 1.2m (cached)\n", + ] + + result = parse_metrics(output_lines) + + assert result is not None + assert result.prompt_tokens == 1300000 + assert result.completion_tokens == 11600 + + def test_parse_session_log_extracts_turn_count(): log_content = """ 2026-01-20T08:55:10.767Z [INFO] --- Start of group: Sending request to the AI model --- diff --git a/tests/test_type_exhaustiveness.py b/tests/test_type_exhaustiveness.py index 3da3844f7..0b8355537 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 from bcbench.types import AgentType, EvaluationCategory @@ -34,8 +35,20 @@ def test_all_categories_have_entry_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")], + ) + 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) From 3ec10a06c600a3badd1aeca6676611fa5371ccf1 Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Mon, 13 Apr 2026 08:46:17 +0200 Subject: [PATCH 09/37] fix merge conflict resolution mistake --- src/bcbench/types.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/bcbench/types.py b/src/bcbench/types.py index 4559cb135..008103cf8 100644 --- a/src/bcbench/types.py +++ b/src/bcbench/types.py @@ -163,32 +163,6 @@ def summary_class(self) -> type[EvaluationResultSummary]: raise ValueError(f"Unknown evaluation category: {self}") - @property - def result_class(self) -> type[BaseEvaluationResult]: - from bcbench.results.bugfix import BugFixResult - from bcbench.results.testgeneration import TestGenerationResult - - match self: - case EvaluationCategory.BUG_FIX: - return BugFixResult - case EvaluationCategory.TEST_GENERATION: - return TestGenerationResult - - raise ValueError(f"Unknown evaluation category: {self}") - - @property - def summary_class(self) -> type[EvaluationResultSummary]: - """Returns the EvaluationResultSummary subclass for this category.""" - from bcbench.results.summary import ExecutionBasedEvaluationResultSummary - - match self: - case EvaluationCategory.BUG_FIX: - return ExecutionBasedEvaluationResultSummary - case EvaluationCategory.TEST_GENERATION: - return ExecutionBasedEvaluationResultSummary - - raise ValueError(f"Unknown evaluation category: {self}") - @property def pipeline(self) -> EvaluationPipeline: from bcbench.evaluate import BugFixPipeline, CodeReviewPipeline, TestGenerationPipeline From a9f59d985946219516a6fc8b1244345dde6323e4 Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Mon, 13 Apr 2026 10:01:53 +0200 Subject: [PATCH 10/37] Make container parameters optional in evaluate and run commands --- src/bcbench/commands/evaluate.py | 30 ++++++++++++++++++------------ src/bcbench/commands/run.py | 4 ++-- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/bcbench/commands/evaluate.py b/src/bcbench/commands/evaluate.py index 1322ac473..43993e6d2 100644 --- a/src/bcbench/commands/evaluate.py +++ b/src/bcbench/commands/evaluate.py @@ -43,10 +43,10 @@ def _prepare_run_dir(output_dir: Path, run_id: str) -> Path: @evaluate_app.command("mini") def evaluate_mini( entry_id: Annotated[str, typer.Argument(help="Entry ID to run")], - container_name: ContainerName, - username: ContainerUsername, - password: ContainerPassword, category: EvaluationCategoryOption, + container_name: ContainerName = "", + username: ContainerUsername = "", + password: ContainerPassword = "", model: FoundryModel = "gpt-5.1-codex-mini", repo_path: RepoPath = _config.paths.testbed_path, output_dir: OutputDir = _config.paths.evaluation_results_path, @@ -62,11 +62,13 @@ def evaluate_mini( logger.info(f"Running evaluation on entry {entry_id} with mini-bc-agent") + container = ContainerConfig(name=container_name, username=username, password=password) if container_name else None + context = EvaluationContext( entry=entry, repo_path=repo_path, result_dir=run_dir, - container=ContainerConfig(name=container_name, username=username, password=password), + container=container, model=model, agent_name="mini-bc-agent", category=category, @@ -91,10 +93,10 @@ def evaluate_mini( @evaluate_app.command("copilot") def evaluate_copilot( entry_id: Annotated[str, typer.Argument(help="Entry ID to run")], - container_name: ContainerName, - username: ContainerUsername, - password: ContainerPassword, category: EvaluationCategoryOption, + container_name: ContainerName = "", + username: ContainerUsername = "", + password: ContainerPassword = "", model: CopilotModel = "claude-haiku-4.5", repo_path: RepoPath = _config.paths.testbed_path, output_dir: OutputDir = _config.paths.evaluation_results_path, @@ -111,11 +113,13 @@ def evaluate_copilot( logger.info(f"Running evaluation on entry {entry_id} with GitHub Copilot CLI") + container = ContainerConfig(name=container_name, username=username, password=password) if container_name else None + context = EvaluationContext( entry=entry, repo_path=repo_path, result_dir=run_dir, - container=ContainerConfig(name=container_name, username=username, password=password), + container=container, model=model, agent_name="GitHub Copilot", category=category, @@ -142,10 +146,10 @@ def evaluate_copilot( @evaluate_app.command("claude") def evaluate_claude_code( entry_id: Annotated[str, typer.Argument(help="Entry ID to run")], - container_name: ContainerName, - username: ContainerUsername, - password: ContainerPassword, category: EvaluationCategoryOption, + container_name: ContainerName = "", + username: ContainerUsername = "", + password: ContainerPassword = "", model: ClaudeCodeModel = "claude-haiku-4-5", repo_path: RepoPath = _config.paths.testbed_path, output_dir: OutputDir = _config.paths.evaluation_results_path, @@ -162,11 +166,13 @@ def evaluate_claude_code( logger.info(f"Running evaluation on entry {entry_id} with Claude Code") + container = ContainerConfig(name=container_name, username=username, password=password) if container_name else None + context = EvaluationContext( entry=entry, repo_path=repo_path, result_dir=run_dir, - container=ContainerConfig(name=container_name, username=username, password=password), + container=container, model=model, agent_name="Claude Code", category=category, diff --git a/src/bcbench/commands/run.py b/src/bcbench/commands/run.py index 4b069155d..9c0248b6f 100644 --- a/src/bcbench/commands/run.py +++ b/src/bcbench/commands/run.py @@ -59,7 +59,7 @@ def run_mini( def run_copilot( entry_id: Annotated[str, typer.Argument(help="Entry ID to run")], category: EvaluationCategoryOption, - container_name: ContainerName, + container_name: ContainerName = "", model: CopilotModel = "claude-haiku-4.5", repo_path: RepoPath = _config.paths.testbed_path, output_dir: OutputDir = _config.paths.evaluation_results_path, @@ -83,7 +83,7 @@ def run_copilot( def run_claude( entry_id: Annotated[str, typer.Argument(help="Entry ID to run")], category: EvaluationCategoryOption, - container_name: ContainerName, + container_name: ContainerName = "", model: ClaudeCodeModel = "claude-haiku-4-5", repo_path: RepoPath = _config.paths.testbed_path, output_dir: OutputDir = _config.paths.evaluation_results_path, From 4ad4bd960a8713950ad272622b149937ff88db62 Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Mon, 13 Apr 2026 10:28:30 +0200 Subject: [PATCH 11/37] Enhance code review functionality by adding expected review comments display and refactoring comment parsing logic --- src/bcbench/commands/dataset.py | 19 ++++++++++- src/bcbench/evaluate/codereview.py | 49 +++++++-------------------- src/bcbench/results/codereview.py | 53 +++++++++++++++++++++++++++--- tests/conftest.py | 7 ++-- tests/test_codereview.py | 4 +-- 5 files changed, 82 insertions(+), 50 deletions(-) diff --git a/src/bcbench/commands/dataset.py b/src/bcbench/commands/dataset.py index 678c9502e..5ed7fda30 100644 --- a/src/bcbench/commands/dataset.py +++ b/src/bcbench/commands/dataset.py @@ -7,7 +7,7 @@ from bcbench.cli_options import EvaluationCategoryOption from bcbench.config import get_config -from bcbench.dataset import BaseDatasetEntry +from bcbench.dataset import BaseDatasetEntry, CodeReviewEntry from bcbench.dataset.dataset_entry import _BugFixTestGenBase from bcbench.exceptions import ConfigurationError from bcbench.logger import get_logger @@ -135,6 +135,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/evaluate/codereview.py b/src/bcbench/evaluate/codereview.py index 9c9ba104b..3de937df5 100644 --- a/src/bcbench/evaluate/codereview.py +++ b/src/bcbench/evaluate/codereview.py @@ -1,8 +1,7 @@ -import json from collections.abc import Callable from pathlib import Path -from bcbench.dataset.codereview import CodeReviewEntry, ReviewComment +from bcbench.dataset.codereview import CodeReviewEntry from bcbench.evaluate.base import EvaluationPipeline from bcbench.logger import get_logger, github_log_group from bcbench.operations import apply_patch, setup_repo_prebuild @@ -16,37 +15,6 @@ __all__ = ["CodeReviewPipeline"] -def _parse_review_json(repo_path: Path) -> list[ReviewComment]: - """Parse review.json produced by the agent into ReviewComment objects. - - NOTE: This is a minimal parser for the POC. The owning team should make this more robust. - """ - review_path = repo_path / REVIEW_OUTPUT_FILE - if not review_path.exists(): - logger.warning(f"No {REVIEW_OUTPUT_FILE} found at {review_path}") - return [] - - try: - raw = json.loads(review_path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - logger.warning(f"Failed to parse {review_path} as JSON") - return [] - - if not isinstance(raw, list): - logger.warning(f"Expected JSON array in {review_path}, got {type(raw).__name__}") - return [] - - comments: list[ReviewComment] = [] - for item in raw: - if not isinstance(item, dict): - continue - try: - comments.append(ReviewComment.model_validate(item)) - except Exception: - logger.debug(f"Skipping malformed comment: {item}") - return comments - - class CodeReviewPipeline(EvaluationPipeline[CodeReviewEntry]): """Pipeline for code-review evaluation category. @@ -66,10 +34,17 @@ def run_agent(self, context: EvaluationContext[CodeReviewEntry], agent_runner: C context.metrics, context.experiment = agent_runner(context) def evaluate(self, context: EvaluationContext[CodeReviewEntry]) -> None: - generated_comments: list[ReviewComment] = _parse_review_json(context.repo_path) - logger.info(f"Parsed {len(generated_comments)} comments from {REVIEW_OUTPUT_FILE}") - result = CodeReviewResult.create_success(context, generated_comments=generated_comments) + review_output_file: Path = context.repo_path / REVIEW_OUTPUT_FILE + + if review_output_file.exists(): + output = review_output_file.read_text(encoding="utf-8") + else: + logger.error(f"No review generated for {context.entry.instance_id}") + raise RuntimeError(f"No review generated for {context.entry.instance_id}") + + result = CodeReviewResult.create_success(context, output=output) + logger.info(f"Parsed {len(result.generated_comments)} comments from {REVIEW_OUTPUT_FILE}") # TODO: Code Review team should implement the real evaluation logic and populate metrics in the result - for comment in generated_comments: + for comment in result.generated_comments: logger.debug(f" {comment}") self.save_result(context, result) diff --git a/src/bcbench/results/codereview.py b/src/bcbench/results/codereview.py index a9bda0d45..655173872 100644 --- a/src/bcbench/results/codereview.py +++ b/src/bcbench/results/codereview.py @@ -1,30 +1,73 @@ from typing import Any, Self -from pydantic import Field +from pydantic import Field, model_validator from bcbench.dataset.codereview import ReviewComment +from bcbench.logger import get_logger from bcbench.results.base import BaseEvaluationResult from bcbench.types import EvaluationContext +logger = get_logger(__name__) + __all__ = ["CodeReviewResult"] +def _parse_review_output(raw_output: str) -> list[ReviewComment]: + """Parse raw JSON output into ReviewComment objects. + + NOTE: This is a minimal parser for the POC. The owning team should make this more robust. + """ + import json + + if not raw_output.strip(): + return [] + + try: + raw = json.loads(raw_output) + except json.JSONDecodeError: + logger.warning("Failed to parse review output as JSON") + return [] + + if not isinstance(raw, list): + logger.warning(f"Expected JSON array in review output, got {type(raw).__name__}") + return [] + + comments: list[ReviewComment] = [] + for item in raw: + if not isinstance(item, dict): + continue + try: + comments.append(ReviewComment.model_validate(item)) + except Exception: + logger.debug(f"Skipping malformed comment: {item}") + return comments + + class CodeReviewResult(BaseEvaluationResult): - """Result for the code-review category.""" + """ + Result for the code-review category. + + TODO: Code Review team should implement the real metrics here. This is just a placeholder to demo. + """ generated_comments: list[ReviewComment] = Field(default_factory=list) + @model_validator(mode="after") + def _parse_comments_from_output(self) -> Self: + if not self.generated_comments and self.output: + object.__setattr__(self, "generated_comments", _parse_review_output(self.output)) + return self + @classmethod def create_success( cls, context: "EvaluationContext", - generated_comments: list[ReviewComment], + output: str, **kwargs: Any, ) -> Self: return cls._create_from_context( context, - output="", - generated_comments=generated_comments, + output=output, **kwargs, ) diff --git a/tests/conftest.py b/tests/conftest.py index 0b64ce050..8d67f96e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -187,19 +187,16 @@ def create_codereview_result( project: str = "Shopify", model: str = "gpt-4o", agent_name: str = "copilot-cli", - generated_comments: list[ReviewComment] | None = None, + output: str = '[{"file": "test.al", "line_start": 5, "body": "Good catch"}]', metrics: AgentMetrics | None = None, ) -> CodeReviewResult: - if generated_comments is None: - generated_comments = [ReviewComment(file="test.al", line_start=5, body="Good catch")] - return CodeReviewResult( instance_id=instance_id, project=project, model=model, agent_name=agent_name, category=EvaluationCategory.CODE_REVIEW, - generated_comments=generated_comments, + output=output, metrics=metrics, ) diff --git a/tests/test_codereview.py b/tests/test_codereview.py index dbcb6ea69..52d9f28ec 100644 --- a/tests/test_codereview.py +++ b/tests/test_codereview.py @@ -51,10 +51,10 @@ def test_create_result(self): assert len(result.generated_comments) == 1 def test_round_trip_serialization(self, tmp_path): - comments = [ReviewComment(file="test.al", line_start=5, body="Good catch")] + output = json.dumps([{"file": "test.al", "line_start": 5, "body": "Good catch"}]) original = create_codereview_result( instance_id="codereview-round-trip", - generated_comments=comments, + output=output, ) original.save(tmp_path, "test.jsonl") From 92951c433faa9488f7ca8b9fb583e108867f8860 Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Mon, 13 Apr 2026 15:59:24 +0200 Subject: [PATCH 12/37] better hanlding container for not required categories --- src/bcbench/commands/evaluate.py | 8 ++++---- src/bcbench/commands/run.py | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/bcbench/commands/evaluate.py b/src/bcbench/commands/evaluate.py index 43993e6d2..dfe8607bc 100644 --- a/src/bcbench/commands/evaluate.py +++ b/src/bcbench/commands/evaluate.py @@ -134,8 +134,8 @@ def evaluate_copilot( category=category, model=ctx.model, output_dir=ctx.result_dir, - al_mcp=al_mcp, - container_name=ctx.get_container().name, + al_mcp=al_mcp if ctx.container else False, + container_name=ctx.get_container().name if ctx.container else "", ), ) @@ -187,8 +187,8 @@ def evaluate_claude_code( category=category, model=ctx.model, output_dir=ctx.result_dir, - al_mcp=al_mcp, - container_name=ctx.get_container().name, + al_mcp=al_mcp if ctx.container else False, + container_name=ctx.get_container().name if ctx.container else "", ), ) diff --git a/src/bcbench/commands/run.py b/src/bcbench/commands/run.py index 9c0248b6f..feb3ab0ba 100644 --- a/src/bcbench/commands/run.py +++ b/src/bcbench/commands/run.py @@ -76,7 +76,15 @@ def run_copilot( entry = category.entry_class.load(category.dataset_path, entry_id=entry_id)[0] category.pipeline.setup_workspace(entry, repo_path) - run_copilot_agent(entry=entry, repo_path=repo_path, model=model, category=category, output_dir=output_dir, al_mcp=al_mcp, container_name=container_name) + run_copilot_agent( + entry=entry, + repo_path=repo_path, + model=model, + category=category, + output_dir=output_dir, + al_mcp=al_mcp if container_name else False, + container_name=container_name if container_name else "", + ) @run_app.command("claude") @@ -100,7 +108,15 @@ def run_claude( entry = category.entry_class.load(category.dataset_path, entry_id=entry_id)[0] category.pipeline.setup_workspace(entry, repo_path) - run_claude_code(entry=entry, repo_path=repo_path, model=model, category=category, output_dir=output_dir, al_mcp=al_mcp, container_name=container_name) + run_claude_code( + entry=entry, + repo_path=repo_path, + model=model, + category=category, + output_dir=output_dir, + al_mcp=al_mcp if container_name else False, + container_name=container_name if container_name else "", + ) @run_app.command("mini-inspector") From aa48a29a1e9f83d8f7014da73e23e93bbae88bee Mon Sep 17 00:00:00 2001 From: "Haoran Sun (Business Central)" Date: Tue, 12 May 2026 15:20:33 +0200 Subject: [PATCH 13/37] prefer copilot.exe executable --- src/bcbench/agent/copilot/agent.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bcbench/agent/copilot/agent.py b/src/bcbench/agent/copilot/agent.py index 12f9bdd41..51023745d 100644 --- a/src/bcbench/agent/copilot/agent.py +++ b/src/bcbench/agent/copilot/agent.py @@ -49,7 +49,9 @@ def run_copilot_agent( logger.info(f"Executing Copilot CLI in directory: {repo_path}") logger.debug(f"Using prompt:\n{prompt}") - copilot_cmd = shutil.which("copilot.cmd") or shutil.which("copilot") + # Prefer copilot.exe over copilot.bat/copilot.cmd shims on Windows: the .bat shim invokes PowerShell, + # which re-parses arguments and corrupts prompts containing double quotes (e.g. JSON examples). + copilot_cmd = shutil.which("copilot.exe") or shutil.which("copilot.cmd") or shutil.which("copilot") if not copilot_cmd: raise AgentError("Copilot CLI not found in PATH. Please ensure it is installed and available.") From a2445036e48beddaf5208653fc507939ad36d48b Mon Sep 17 00:00:00 2001 From: Wael AbuSeada Date: Sat, 16 May 2026 12:48:55 -0600 Subject: [PATCH 14/37] Normalize code-review dataset and preserve eval outputs --- .github/workflows/summarize-results.yml | 10 +- dataset/codereview.jsonl | 83 +- evaluator/scores.py | 20 + scripts/run_all_codereview_evals.ps1 | 66 ++ src/bcbench/agent/claude/agent.py | 5 + src/bcbench/agent/copilot/agent.py | 5 + src/bcbench/agent/shared/config.yaml | 10 +- .../microsoft-BCApps/instructions/UI.md | 445 ++++++++ .../instructions/accessibility.md | 672 +++++++++++ .../instructions/performance.md | 708 ++++++++++++ .../microsoft-BCApps/instructions/privacy.md | 436 +++++++ .../microsoft-BCApps/instructions/security.md | 728 ++++++++++++ .../microsoft-BCApps/instructions/style.md | 1001 +++++++++++++++++ .../microsoft-BCApps/instructions/upgrade.md | 621 ++++++++++ .../skills/al-code-review/SKILL.md | 275 +++++ src/bcbench/config.py | 2 +- src/bcbench/dataset/codereview.py | 2 +- src/bcbench/evaluate/codereview.py | 19 +- src/bcbench/results/codereview.py | 255 ++++- src/bcbench/results/summary.py | 70 +- tests/conftest.py | 7 + tests/test_codereview.py | 106 ++ 22 files changed, 5506 insertions(+), 40 deletions(-) create mode 100644 scripts/run_all_codereview_evals.ps1 create mode 100644 src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/UI.md create mode 100644 src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/accessibility.md create mode 100644 src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/performance.md create mode 100644 src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/privacy.md create mode 100644 src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/security.md create mode 100644 src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/style.md create mode 100644 src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/upgrade.md create mode 100644 src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/SKILL.md diff --git a/.github/workflows/summarize-results.yml b/.github/workflows/summarize-results.yml index 54201a583..1dfff8ab6 100644 --- a/.github/workflows/summarize-results.yml +++ b/.github/workflows/summarize-results.yml @@ -75,6 +75,14 @@ jobs: BRAINTRUST_PROJECT_ID: ${{ secrets.BRAINTRUST_PROJECT_ID }} BRAINTRUST_API_KEY: ${{ secrets.BRAINTRUST_API_KEY }} run: | + if [ "${{ inputs.category }}" = "code-review" ]; then + EVALUATORS="precision_score,recall_score,f1_score,valid_review_output" + elif [ "${{ inputs.category }}" = "test-generation" ]; then + EVALUATORS="resolution_rate,build_rate,pre_patch_failed_rate,post_patch_passed_rate" + else + EVALUATORS="resolution_rate,build_rate" + fi + # Get Azure DevOps access token from Azure CLI (uses the OIDC token from azure/login) ADO_TOKEN=$(az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv) echo "::add-mask::$ADO_TOKEN" @@ -88,7 +96,7 @@ jobs: bceval metrics calculate \ --input-file "${{ inputs.results-dir }}/${{ github.run_id }}/${{ env.BCEVAL_RESULT_FILE }}" \ --evaluator-definitions "${{ github.workspace }}/evaluator/scores.py" \ - --evaluators "resolution_rate,build_rate${{ inputs.category == 'test-generation' && ',pre_patch_failed_rate,post_patch_passed_rate' || '' }}" \ + --evaluators "${EVALUATORS}" \ --metric-definitions "${{ github.workspace }}/evaluator/metrics.py" \ --metrics "bc_bench_metrics" \ --eval-run-name "${{ inputs.agent }} (${{ inputs.model }}) - #${{ github.run_id }}" \ diff --git a/dataset/codereview.jsonl b/dataset/codereview.jsonl index 61bac29cd..6a6185bd5 100644 --- a/dataset/codereview.jsonl +++ b/dataset/codereview.jsonl @@ -1 +1,82 @@ -{"metadata": {"area": "shopify"}, "repo": "microsoft/BCApps", "instance_id": "microsoft__BCApps-4699", "base_commit": "effc43e8f96bc2b06545bcf81b9579bd08542747", "created_at": "2025-09-05T11:48:36Z", "environment_setup_version": "27.0", "project_paths": ["src\\Apps\\W1\\Shopify\\App", "src\\Apps\\W1\\Shopify\\Test"], "patch": "diff --git a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al\nindex 4e6ffd2866..717c4f204b 100644\n--- a/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al\n+++ b/src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al\n@@ -8,6 +8,7 @@ namespace Microsoft.Integration.Shopify;\n using Microsoft.Inventory.Item;\n using Microsoft.Foundation.UOM;\n using Microsoft.Purchases.Vendor;\n+using Microsoft.Finance.Currency;\n using Microsoft.Inventory.Item.Catalog;\n \n /// \n@@ -230,6 +231,7 @@ codeunit 30171 \"Shpfy Create Item\"\n ItemCategory: Record \"Item Category\";\n ItemVariant: Record \"Item Variant\";\n Vendor: Record Vendor;\n+ CurrencyExchangeRate: Record \"Currency Exchange Rate\";\n CurrentTemplateCode: Code[20];\n ItemNo: Code[20];\n Code: Text;\n@@ -258,10 +260,16 @@ codeunit 30171 \"Shpfy Create Item\"\n CreateItemUnitOfMeasure(ShopifyVariant, Item);\n \n if ShopifyVariant.\"Unit Cost\" <> 0 then\n- Item.Validate(\"Unit Cost\", ShopifyVariant.\"Unit Cost\");\n+ if Shop.\"Currency Code\" = '' then\n+ Item.Validate(\"Unit Cost\", ShopifyVariant.\"Unit Cost\")\n+ else\n+ Item.Validate(\"Unit Cost\", Round(CurrencyExchangeRate.ExchangeAmtFCYToLCY(WorkDate(), Shop.\"Currency Code\", ShopifyVariant.\"Unit Cost\", CurrencyExchangeRate.ExchangeRate(WorkDate(), Shop.\"Currency Code\"))));\n \n if ShopifyVariant.Price <> 0 then\n- Item.Validate(\"Unit Price\", ShopifyVariant.Price);\n+ if Shop.\"Currency Code\" = '' then\n+ Item.Validate(\"Unit Price\", ShopifyVariant.Price)\n+ else\n+ Item.Validate(\"Unit Price\", Round(CurrencyExchangeRate.ExchangeAmtFCYToLCY(WorkDate(), Shop.\"Currency Code\", ShopifyVariant.Price, CurrencyExchangeRate.ExchangeRate(WorkDate(), Shop.\"Currency Code\"))));\n \n if ShopifyProduct.\"Product Type\" <> '' then begin\n ItemCategory.SetFilter(Description, FilterMgt.CleanFilterValue(ShopifyProduct.\"Product Type\", MaxStrLen(ItemCategory.Description)));\n", "expected_comments": [{"file": "src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al", "line_start": 263, "line_end": 266, "body": "The currency conversion logic for Unit Cost and Unit Price is duplicated. Consider extracting a helper procedure to convert FCY to LCY to reduce repetition and improve maintainability.", "severity": "suggestion"}, {"file": "src/Apps/W1/Shopify/App/src/Products/Codeunits/ShpfyCreateItem.Codeunit.al", "line_start": 265, "body": "Round() is called without specifying a rounding precision. This defaults to the system precision which may not match the currency's amount rounding precision. Consider using the currency's Amount Rounding Precision.", "severity": "warning"}]} +{"repo": "microsoft/BCApps", "instance_id": "security-001", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/SecureApiManager.Codeunit.al\n+++ src/SecureApiManager.Codeunit.al\n+codeunit 50100 \"Secure Api Manager\"\n+{\n+ Access = Internal;\n+\n+ [NonDebuggable]\n+ internal procedure StoreApiKey(ConfigCode: Code[20]; ApiKey: SecretText)\n+ begin\n+ IsolatedStorage.SetEncrypted(GetStorageKey(ConfigCode), ApiKey, DataScope::Module);\n+ end;\n+\n+ [NonDebuggable]\n+ internal procedure GetApiKey(ConfigCode: Code[20]) Result: SecretText\n+ begin\n+ if not IsolatedStorage.Contains(GetStorageKey(ConfigCode), DataScope::Module) then\n+ Error('API key not configured for %1.', ConfigCode);\n+ IsolatedStorage.Get(GetStorageKey(ConfigCode), DataScope::Module, Result);\n+ end;\n+\n+ procedure CallApi(ConfigCode: Code[20])\n+ var\n+ Client: HttpClient;\n+ Headers: HttpHeaders;\n+ Response: HttpResponseMessage;\n+ AuthHeader: SecretText;\n+ begin\n+ AuthHeader := SecretStrSubstNo('Bearer %1', GetApiKey(ConfigCode));\n+ Headers := Client.DefaultRequestHeaders();\n+ Headers.Add('Authorization', AuthHeader);\n+\n+ if not Client.Get('https://api.businesscentral.dynamics.com/v2.0/data', Response) then\n+ Error('Request failed. Check configuration.');\n+\n+ if not Response.IsSuccessStatusCode then\n+ Error('Request failed. Check configuration.');\n+ end;\n+\n+ local procedure GetStorageKey(ConfigCode: Code[20]): Text[50]\n+ begin\n+ exit('ApiKey_' + ConfigCode);\n+ end;\n+}\n+", "expected_comments": [], "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": "security-002", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/SecureKeyManager.Codeunit.al\n+++ src/SecureKeyManager.Codeunit.al\n+codeunit 50101 \"Secure Key Manager\"\n+{\n+ Access = Internal;\n+\n+ [NonDebuggable]\n+ procedure StoreEncryptedKey(KeyName: Code[20]; KeyValue: SecretText)\n+ begin\n+ if IsolatedStorage.Contains(KeyName, DataScope::Module) then\n+ IsolatedStorage.Delete(KeyName, DataScope::Module);\n+\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('Key not found. Please configure it first.');\n+\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+\n+ procedure RemoveKey(KeyName: Code[20])\n+ begin\n+ if IsolatedStorage.Contains(KeyName, DataScope::Module) then\n+ IsolatedStorage.Delete(KeyName, DataScope::Module);\n+ end;\n+}\n+", "expected_comments": [], "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": "security-003", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/SafeErrorHandler.Codeunit.al\n+++ src/SafeErrorHandler.Codeunit.al\n+codeunit 50102 \"Safe Error Handler\"\n+{\n+ procedure HandleApiResponse(StatusCode: Integer): Text\n+ begin\n+ case StatusCode of\n+ 200, 201:\n+ exit('');\n+ 400:\n+ exit('Invalid request. Please check your input.');\n+ 401:\n+ exit('Authentication failed. Please verify your credentials.');\n+ 403:\n+ exit('You do not have permission for this operation.');\n+ 404:\n+ exit('The requested resource was not found.');\n+ else\n+ exit('An unexpected error occurred. Contact your administrator.');\n+ end;\n+ end;\n+\n+ procedure TryPostDocument(DocNo: Code[20]): Boolean\n+ var\n+ Success: Boolean;\n+ begin\n+ Success := TryPost(DocNo);\n+ if not Success then\n+ Error('Could not post document %1. Please review and try again.', DocNo);\n+ exit(true);\n+ end;\n+\n+ [TryFunction]\n+ local procedure TryPost(DocNo: Code[20])\n+ begin\n+ // Posting logic here\n+ end;\n+}\n+", "expected_comments": [], "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": "security-004", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/AppConstants.Codeunit.al\n+++ src/AppConstants.Codeunit.al\n+codeunit 50103 \"App Constants\"\n+{\n+ procedure GetApiVersion(): Text\n+ begin\n+ exit('v2.0');\n+ end;\n+\n+ procedure GetDefaultCurrency(): Code[10]\n+ begin\n+ exit('USD');\n+ end;\n+\n+ procedure GetMaxPageSize(): Integer\n+ begin\n+ exit(100);\n+ end;\n+\n+ procedure GetDateFormat(): Text\n+ begin\n+ exit('yyyy-MM-dd');\n+ end;\n+\n+ procedure GetAppId(): Text\n+ begin\n+ exit('BC-INVENTORY-APP');\n+ end;\n+\n+ var\n+ DefaultLanguageLbl: Label 'en-US';\n+ NoItemsFoundLbl: Label 'No items found matching your criteria.';\n+ PostingConfirmQst: Label 'Do you want to post the document?';\n+}\n+", "expected_comments": [], "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": "security-005", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/ValidatedImportConfig.Table.al\n+++ src/ValidatedImportConfig.Table.al\n+table 50104 \"Validated Import Config\"\n+{\n+ DataClassification = CustomerContent;\n+ Caption = 'Validated Import Configuration';\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; \"Created By\"; Code[50])\n+ {\n+ Caption = 'Created By';\n+ Editable = false;\n+ }\n+ field(4; \"Max Records\"; Integer)\n+ {\n+ Caption = 'Maximum Records';\n+ MinValue = 1;\n+ MaxValue = 10000;\n+\n+ trigger OnValidate()\n+ begin\n+ if \"Max Records\" > 5000 then\n+ Message('Large imports may take longer to process.');\n+ end;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Code\") { Clustered = true; }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ \"Created By\" := CopyStr(UserId(), 1, MaxStrLen(\"Created By\"));\n+ end;\n+}\n+", "expected_comments": [], "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": "security-006", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/InventoryEditor.PermissionSet.al\n+++ src/InventoryEditor.PermissionSet.al\n+permissionset 50107 \"Inventory Editor\"\n+{\n+ Caption = 'Inventory Editor';\n+ Assignable = true;\n+\n+ Permissions =\n+ tabledata Item = ri,\n+ tabledata \"Item Journal Line\" = ri,\n+ tabledata \"Item Category\" = r,\n+ codeunit \"Inventory Lookup\" = X,\n+ codeunit \"Inventory Post\" = X;\n+}\n+\n--- src/InventoryLookup.Codeunit.al\n+++ src/InventoryLookup.Codeunit.al\n+codeunit 50108 \"Inventory Lookup\"\n+{\n+ Permissions =\n+ tabledata Item = r;\n+\n+ procedure FindItem(ItemNo: Code[20]): Text\n+ var\n+ Item: Record Item;\n+ begin\n+ if Item.Get(ItemNo) then\n+ exit(Item.Description);\n+ exit('');\n+ end;\n+}\n+\n--- src/InventoryReader.PermissionSet.al\n+++ src/InventoryReader.PermissionSet.al\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+ tabledata \"Stockkeeping Unit\" = r,\n+ codeunit \"Inventory Lookup\" = X;\n+}\n+", "expected_comments": [], "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": "security-007", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/SafeRecordQuery.Codeunit.al\n+++ src/SafeRecordQuery.Codeunit.al\n+codeunit 50109 \"Safe Record Query\"\n+{\n+ procedure FindCustomersByCity(City: Text[30]): Integer\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.SetRange(City, City);\n+ exit(Customer.Count());\n+ end;\n+\n+ procedure FindOpenSalesOrders(CustNo: 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.\", CustNo);\n+ SalesHeader.SetFilter(Status, '%1', SalesHeader.Status::Open);\n+ exit(SalesHeader.FindSet());\n+ end;\n+\n+ procedure GetItemsAbovePrice(MinPrice: Decimal): Integer\n+ var\n+ Item: Record Item;\n+ begin\n+ Item.SetFilter(\"Unit Price\", '>%1', MinPrice);\n+ exit(Item.Count());\n+ end;\n+\n+ procedure FindVendorByName(NameFilter: Text[100]): Code[20]\n+ var\n+ Vendor: Record Vendor;\n+ begin\n+ Vendor.SetFilter(Name, '@*' + NameFilter + '*');\n+ if Vendor.FindFirst() then\n+ exit(Vendor.\"No.\");\n+ exit('');\n+ end;\n+}\n+", "expected_comments": [], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "Clean codeunit using proper BC record operations: SetRange, SetFilter, FindSet, Count \u2014 no string concatenation or dynamic SQL", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "security-008", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/PartnerManagedConfigSetup.Page.al\n+++ src/PartnerManagedConfigSetup.Page.al\n+page 50100 \"Partner Managed Config Setup\"\n+{\n+ PageType = Card;\n+ ApplicationArea = All;\n+ UsageCategory = Administration;\n+ SourceTable = \"Partner Managed Config\";\n+ Caption = 'Partner Managed Configuration Setup';\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ group(APIConfiguration)\n+ {\n+ Caption = 'API Configuration';\n+ field(EnableGPT; Rec.\"Enable GPT\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Enable GPT functionality';\n+ }\n+ field(GPTEndpoint; Rec.\"GPT Endpoint\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specify the GPT service endpoint';\n+ }\n+ field(GPT41ApiKey; Gpt41ApiKey)\n+ {\n+ ApplicationArea = All;\n+ ExtendedDatatype = Masked;\n+ ToolTip = 'Enter the GPT-4.1 API key';\n+\n+ trigger OnValidate()\n+ begin\n+ if Gpt41ApiKey <> '' then\n+ Rec.SetApiKey(Gpt41ApiKey, 'sdlkjfhbslkdjfbkjhbsdf');\n+ end;\n+ }\n+ field(GPT41MiniApiKey; Gpt41miniApiKey)\n+ {\n+ ApplicationArea = All;\n+ ExtendedDatatype = Masked;\n+ ToolTip = 'Enter the GPT-4.1 Mini API key';\n+ }\n+ field(GPT52ApiKey; Gpt52ApiKey)\n+ {\n+ ApplicationArea = All;\n+ ExtendedDatatype = Masked;\n+ ToolTip = 'Enter the GPT-5.2 API key';\n+ }\n+ }\n+ group(GeneralSettings)\n+ {\n+ Caption = 'General Settings';\n+ field(MaxTokens; Rec.\"Max Tokens\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Maximum number of tokens per request';\n+ }\n+ field(Temperature; Rec.Temperature)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Control randomness in responses (0.0 to 1.0)';\n+ }\n+ }\n+ }\n+ }\n+\n+ var\n+ [NonDebuggable]\n+ Gpt41ApiKey: Text;\n+ [NonDebuggable]\n+ Gpt41miniApiKey: Text;\n+ [NonDebuggable]\n+ Gpt52ApiKey: Text;\n+}", "expected_comments": [{"file": "src/PartnerManagedConfigSetup.Page.al", "line_start": 35, "line_end": 35, "body": "API key stored as plain Text variable and passed to SetApiKey without encryption \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/PartnerManagedConfigSetup.Page.al", "line_start": 38, "line_end": 38, "body": "GPT-4.1 Mini API key field has no OnValidate/SetApiKey call \u2014 key stored as plain text \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/PartnerManagedConfigSetup.Page.al", "line_start": 44, "line_end": 44, "body": "GPT-5.2 API key field has no OnValidate/SetApiKey call \u2014 key stored as plain text \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/PartnerManagedConfigSetup.Page.al", "line_start": 70, "line_end": 70, "body": "API key variables declared as Text instead of SecretText \u2014 visible in debugger \u2014 See agent comment for details.", "severity": "medium"}], "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": "security-009", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/EAEmailSendDispatcher.Codeunit.al\n+++ src/EAEmailSendDispatcher.Codeunit.al\n+codeunit 50200 \"EA Email Send Dispatcher\"\n+{\n+ TableNo = \"Email Message\";\n+\n+ var\n+ EmailScenario: Enum \"Email Scenario\";\n+ NoEmailRegisteredErr: Label 'No email account is registered for the Expense Agent';\n+ EmailFailedErr: Label 'Failed to send email: %1';\n+ EmailSentMsg: Label 'Email sent successfully to %1';\n+\n+ trigger OnRun()\n+ var\n+ EmailAccount: Record \"Email Account\";\n+ Email: Codeunit Email;\n+ EmailMessage: Codeunit \"Email Message\";\n+ begin\n+ InitializeEmailSettings();\n+\n+ if not EmailAccount.FindFirst() then\n+ Error(NoEmailRegisteredErr);\n+\n+ ProcessEmailMessage(Rec, EmailAccount);\n+ end;\n+\n+ procedure SendNotificationEmail(Recipients: List of [Text]; Subject: Text; Body: Text)\n+ var\n+ EmailAccount: Record \"Email Account\";\n+ Email: Codeunit Email;\n+ EmailMessage: Codeunit \"Email Message\";\n+ EmailRecipient: Text;\n+ EmailAddress: Text;\n+ i: Integer;\n+ begin\n+ if Recipients.Count = 0 then\n+ Error('No recipients specified for notification email.');\n+\n+ if not EmailAccount.FindFirst() then\n+ Error(NoEmailRegisteredErr);\n+\n+ EmailMessage.Create(Recipients, Subject, Body, true);\n+\n+ EmailMessage.AddCustomDimension('EmailType', 'Notification');\n+ EmailMessage.AddCustomDimension('Source', 'ExpenseAgent');\n+ EmailMessage.AddCustomDimension('Timestamp', Format(CurrentDateTime));\n+\n+ if Email.Send(EmailMessage, EmailAccount.\"Account Id\") then begin\n+ for i := 1 to Recipients.Count do begin\n+ Recipients.Get(i, EmailRecipient);\n+ Message(EmailSentMsg, EmailRecipient);\n+ end;\n+ end else\n+ Error(EmailFailedErr, GetLastErrorText());\n+ end;\n+\n+ local procedure InitializeEmailSettings()\n+ var\n+ EmailSetup: Record \"Email Setup\";\n+ FeatureManagement: Record \"Feature Management\";\n+ begin\n+ if not EmailSetup.Get() then begin\n+ EmailSetup.Init();\n+ EmailSetup.\"Enable Email\" := true;\n+ EmailSetup.\"Default Scenario\" := EmailScenario::\"Expense Management\";\n+ EmailSetup.Insert();\n+ end;\n+\n+ FeatureManagement.SetRange(\"Feature Key\", 'EmailIntegration');\n+ if FeatureManagement.FindFirst() then\n+ if not FeatureManagement.Enabled then\n+ Error('Email integration feature is not enabled.');\n+ end;\n+\n+ local procedure ProcessEmailMessage(var EmailMessage: Record \"Email Message\"; EmailAccount: Record \"Email Account\")\n+ var\n+ Email: Codeunit Email;\n+ EmailObj: Codeunit \"Email Message\";\n+ AttachmentPath: Text;\n+ ProcessingResult: Boolean;\n+ begin\n+ EmailObj.Get(EmailMessage.Id);\n+\n+ AttachmentPath := GetDefaultAttachmentPath();\n+ if AttachmentPath <> '' then\n+ EmailObj.AddAttachment(AttachmentPath, 'Expense_Guidelines.pdf');\n+\n+ ApplyEmailTemplate(EmailObj);\n+\n+ ProcessingResult := Email.Send(EmailObj, EmailAccount.\"Account Id\");\n+\n+ EmailMessage.\"Send Status\" := GetSendStatus(ProcessingResult);\n+ EmailMessage.\"Sent DateTime\" := CurrentDateTime;\n+ EmailMessage.Modify();\n+\n+ if not ProcessingResult then\n+ Error(EmailFailedErr, GetLastErrorText());\n+ end;\n+\n+ local procedure GetDefaultAttachmentPath(): Text\n+ begin\n+ exit('\\\\shared\\attachments\\');\n+ end;\n+\n+ local procedure ApplyEmailTemplate(var EmailMessage: Codeunit \"Email Message\")\n+ begin\n+ EmailMessage.AddCc('expense-admin@company.com');\n+ EmailMessage.SetSenderDisplayName('Expense Management System');\n+ end;\n+\n+ local procedure GetSendStatus(Success: Boolean): Enum \"Email Send Status\"\n+ begin\n+ if Success then\n+ exit(\"Email Send Status\"::Sent)\n+ else\n+ exit(\"Email Send Status\"::Failed);\n+ end;\n+}\n--- src/OutlookIndividualDeployment.Page.al\n+++ src/OutlookIndividualDeployment.Page.al\n+page 1690 \"Outlook Individual Deployment\"\n+{\n+ Caption = 'Outlook Add-in Individual Deployment';\n+ PageType = Card;\n+ SourceTable = \"Office Add-in Setup\";\n+ UsageCategory = Administration;\n+ ApplicationArea = Basic, Suite;\n+\n+ layout\n+ {\n+ area(content)\n+ {\n+ group(General)\n+ {\n+ Caption = 'General';\n+ field(\"Application ID\"; Rec.\"Application ID\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Editable = false;\n+ ToolTip = 'Specifies the unique identifier for the Outlook add-in.';\n+ }\n+ field(Name; Rec.Name)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the name of the Outlook add-in.';\n+ }\n+ field(Description; Rec.Description)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the description of the Outlook add-in.';\n+ }\n+ field(Version; Rec.Version)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Editable = false;\n+ ToolTip = 'Specifies the version of the Outlook add-in.';\n+ }\n+ }\n+ group(Deployment)\n+ {\n+ Caption = 'Deployment Settings';\n+ field(\"Manifest File\"; Rec.\"Manifest File\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the manifest file location for the add-in.';\n+ }\n+ field(\"Deployment Status\"; Rec.\"Deployment Status\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Editable = false;\n+ ToolTip = 'Shows the current deployment status.';\n+ }\n+ field(\"Last Deployment Date\"; Rec.\"Last Deployment Date\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Editable = false;\n+ ToolTip = 'Shows when the add-in was last deployed.';\n+ }\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(Processing)\n+ {\n+ action(Deploy)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'Deploy Add-in';\n+ Image = Setup;\n+ ToolTip = 'Deploy the Outlook add-in to the current user.';\n+\n+ trigger OnAction()\n+ begin\n+ DeployAddinForCurrentUser();\n+ end;\n+ }\n+ action(TestConnection)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'Test Connection';\n+ Image = TestConnection;\n+ ToolTip = 'Test the connection to the Outlook service.';\n+\n+ trigger OnAction()\n+ begin\n+ TestOutlookConnection();\n+ end;\n+ }\n+ }\n+ }\n+\n+ local procedure DeployAddinForCurrentUser()\n+ var\n+ OutlookAddinMgt: Codeunit \"Outlook Add-in Management\";\n+ HttpClient: HttpClient;\n+ HttpResponse: HttpResponseMessage;\n+ ResponseText: Text;\n+ DeploymentUrl: Text;\n+ Headers: HttpHeaders;\n+ Content: HttpContent;\n+ JsonPayload: Text;\n+ begin\n+ if Rec.\"Application ID\" = '' then\n+ Error('Application ID is required for deployment.');\n+\n+ if Rec.\"Manifest File\" = '' then\n+ Error('Manifest file path is required.');\n+\n+ DeploymentUrl := GetDeploymentEndpoint();\n+ JsonPayload := BuildDeploymentPayload();\n+\n+ Content.WriteFrom(JsonPayload);\n+ Content.GetHeaders(Headers);\n+ Headers.Clear();\n+ Headers.Add('Content-Type', 'application/json');\n+ Headers.Add('Authorization', GetAuthorizationHeader());\n+\n+ if HttpClient.Post(DeploymentUrl, Content, HttpResponse) then begin\n+ HttpResponse.Content.ReadAs(ResponseText);\n+\n+ if HttpResponse.IsSuccessStatusCode then begin\n+ Rec.\"Deployment Status\" := Rec.\"Deployment Status\"::Deployed;\n+ Rec.\"Last Deployment Date\" := Today;\n+ Rec.Modify();\n+ Message('Add-in deployed successfully.');\n+ end else begin\n+ Error('Deployment failed with HTTP status code %1: %2',\n+ HttpResponse.HttpStatusCode, ResponseText);\n+ end;\n+ end else\n+ Error('Failed to connect to deployment service: %1', GetLastErrorText());\n+ end;\n+\n+ local procedure TestOutlookConnection(): Boolean\n+ var\n+ HttpClient: HttpClient;\n+ HttpResponse: HttpResponseMessage;\n+ TestUrl: Text;\n+ Headers: HttpHeaders;\n+ ResponseText: Text;\n+ begin\n+ TestUrl := GetTestEndpoint();\n+\n+ HttpClient.DefaultRequestHeaders.Add('Authorization', GetAuthorizationHeader());\n+ HttpClient.DefaultRequestHeaders.Add('User-Agent', 'Business Central Outlook Add-in');\n+\n+ if HttpClient.Get(TestUrl, HttpResponse) then begin\n+ if HttpResponse.IsSuccessStatusCode then begin\n+ Message('Connection test successful.');\n+ exit(true);\n+ end else begin\n+ HttpResponse.Content.ReadAs(ResponseText);\n+ Message('Connection test failed: %1', ResponseText);\n+ exit(false);\n+ end;\n+ end else begin\n+ Message('Connection test failed: %1', GetLastErrorText());\n+ exit(false);\n+ end;\n+ end;\n+\n+ local procedure GetDeploymentEndpoint(): Text\n+ begin\n+ exit('https://outlook.office365.com/api/v2.0/addins/deploy');\n+ end;\n+\n+ local procedure GetTestEndpoint(): Text\n+ begin\n+ exit('https://outlook.office365.com/api/v2.0/me');\n+ end;\n+\n+ local procedure GetAuthorizationHeader(): Text\n+ var\n+ AccessToken: Text;\n+ begin\n+ AccessToken := GetOffice365AccessToken();\n+ exit('Bearer ' + AccessToken);\n+ end;\n+\n+ local procedure GetOffice365AccessToken(): Text\n+ begin\n+ exit('dummy_access_token_for_testing');\n+ end;\n+\n+ local procedure BuildDeploymentPayload(): Text\n+ begin\n+ exit('{\"manifestUrl\":\"' + Rec.\"Manifest File\" + '\",\"deploymentType\":\"User\"}');\n+ end;\n+}", "expected_comments": [{"file": "src/OutlookIndividualDeployment.Page.al", "line_start": 184, "line_end": 184, "body": "Hardcoded dummy access token in source code \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/OutlookIndividualDeployment.Page.al", "line_start": 174, "line_end": 174, "body": "Access token returned as plain Text instead of SecretText \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/OutlookIndividualDeployment.Page.al", "line_start": 129, "line_end": 129, "body": "Error exposes HTTP status code and raw response body \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/OutlookIndividualDeployment.Page.al", "line_start": 189, "line_end": 189, "body": "JSON injection via manifest file path concatenation \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/OutlookIndividualDeployment.Page.al", "line_start": 133, "line_end": 133, "body": "GetLastErrorText() exposed to user in error message \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/OutlookIndividualDeployment.Page.al", "line_start": 155, "line_end": 155, "body": "Raw server response exposed in failure message \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/EAEmailSendDispatcher.Codeunit.al", "line_start": 95, "line_end": 95, "body": "Error() with GetLastErrorText() exposes system details \u2014 See agent comment for details.", "severity": "medium"}], "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": "security-010", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/PaymentGatewayConnector.Codeunit.al\n+++ src/PaymentGatewayConnector.Codeunit.al\n+codeunit 50300 \"Payment Gateway Connector\"\n+{\n+ Access = Internal;\n+\n+ var\n+ ConnectionFailedErr: Label 'Payment gateway connection failed.';\n+ GatewayApiSecret: Label 'my-secret-api-key-do-not-commit-this';\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+ HttpClient: HttpClient;\n+ Headers: HttpHeaders;\n+ Response: HttpResponseMessage;\n+ begin\n+ Headers := HttpClient.DefaultRequestHeaders();\n+ Headers.Add('Authorization', 'Basic ' + ApiKey);\n+ HttpClient.Get('https://api.paymentgateway.com/v1/setup', Response);\n+\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, "body": "API secret stored in a Label constant \u2014 secrets must never be in Labels or Text constants \u2014 Store the secret in IsolatedStorage using SetEncrypted and retrieve at runtime", "severity": "critical"}, {"file": "src/PaymentGatewayConnector.Codeunit.al", "line_start": 13, "line_end": 13, "body": "Hardcoded API key assigned directly in code \u2014 Retrieve the API key from IsolatedStorage or Azure Key Vault", "severity": "critical"}, {"file": "src/PaymentGatewayConnector.Codeunit.al", "line_start": 17, "line_end": 17, "body": "Public procedure GetStoredCredential accesses IsolatedStorage \u2014 must be local or internal \u2014 Change procedure access to local or internal to prevent other extensions from reading secrets", "severity": "high"}, {"file": "src/PaymentGatewayConnector.Codeunit.al", "line_start": 26, "line_end": 26, "body": "Public procedure StoreCredential accesses IsolatedStorage \u2014 must be local or internal \u2014 Change procedure access to local or internal and add [NonDebuggable] attribute", "severity": "high"}, {"file": "src/PaymentGatewayConnector.Codeunit.al", "line_start": 38, "line_end": 38, "body": "API key is handled as plain Text and concatenated into the Authorization header \u2014 Use SecretText for the API key and compose the Bearer header with SecretStrSubstNo before adding it to request headers", "severity": "high"}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "True positive security findings: hardcoded_credentials \u2014 hardcoded API key, secret in Label, public IsolatedStorage access", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "security-011", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/ContactSyncUser.Table.al\n+++ src/ContactSyncUser.Table.al\n+table 5309 \"Contact Sync User\"\n+{\n+ Caption = 'Contact Sync User';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Primary Key\"; Code[10])\n+ {\n+ Caption = 'Primary Key';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(10; \"User ID\"; Code[50])\n+ {\n+ Caption = 'User ID';\n+ DataClassification = EndUserIdentifiableInformation;\n+ TableRelation = User.\"User Name\";\n+ ValidateTableRelation = false;\n+\n+ trigger OnValidate()\n+ begin\n+ ValidateUserConfiguration();\n+ end;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Primary Key\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+}\n+\n--- src/ElecVATSetup.Table.al\n+++ src/ElecVATSetup.Table.al\n+table 13610 \"Elec VAT Setup\"\n+{\n+ Caption = 'Electronic VAT Setup';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Primary Key\"; Code[10])\n+ {\n+ Caption = 'Primary Key';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(20; \"Service URL\"; Text[250])\n+ {\n+ Caption = 'Service URL';\n+ DataClassification = CustomerContent;\n+\n+ trigger OnValidate()\n+ begin\n+ ValidateServiceUrl();\n+ end;\n+ }\n+ field(80; \"VAT Authority URL\"; Text[250])\n+ {\n+ Caption = 'VAT Authority URL';\n+ DataClassification = CustomerContent;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Primary Key\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ local procedure ValidateServiceUrl()\n+ begin\n+ if \"Service URL\" = '' then\n+ exit;\n+\n+ if not (\"Service URL\".StartsWith('https://')) then\n+ Error('Service URL must use HTTPS protocol for security.');\n+ end;\n+\n+ procedure SubmitVATReturn(VATReturnNo: Code[20]): Boolean\n+ var\n+ HttpClient: HttpClient;\n+ HttpResponse: HttpResponseMessage;\n+ RequestContent: HttpContent;\n+ begin\n+ if \"Service URL\" = '' then\n+ Error('Service URL must be configured.');\n+\n+ RequestContent := BuildSubmissionContent();\n+\n+ HttpClient.Post(\"Service URL\" + '/submit', RequestContent, HttpResponse);\n+\n+ exit(HttpResponse.IsSuccessStatusCode);\n+ end;\n+\n+ local procedure BuildSubmissionContent(): HttpContent\n+ var\n+ JsonObject: JsonObject;\n+ Content: HttpContent;\n+ JsonText: Text;\n+ begin\n+ JsonObject.Add('testMode', true);\n+ JsonObject.WriteTo(JsonText);\n+ Content.WriteFrom(JsonText);\n+ exit(Content);\n+ end;\n+}\n--- src/ExpenseAgentSetup.Table.al\n+++ src/ExpenseAgentSetup.Table.al\n+table 50500 \"Expense Agent Setup\"\n+{\n+ Caption = 'Expense Agent Setup';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Primary Key\"; Code[10])\n+ {\n+ Caption = 'Primary Key';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(60; \"Service URL\"; Text[250])\n+ {\n+ Caption = 'Agent Service URL';\n+ DataClassification = CustomerContent;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Primary Key\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ procedure TestConnection(): Boolean\n+ var\n+ HttpClient: HttpClient;\n+ HttpResponse: HttpResponseMessage;\n+ IsSuccess: Boolean;\n+ begin\n+ if \"Service URL\" = '' then\n+ Error('Service URL must be configured before testing connection.');\n+\n+ IsSuccess := HttpClient.Get(\"Service URL\" + '/health', HttpResponse);\n+\n+ if IsSuccess and HttpResponse.IsSuccessStatusCode then begin\n+ Message('Connection test successful.');\n+ exit(true);\n+ end else begin\n+ Message('Connection test failed.');\n+ exit(false);\n+ end;\n+ end;\n+}\n+", "expected_comments": [{"file": "src/ContactSyncUser.Table.al", "line_start": 18, "line_end": 18, "body": "ValidateTableRelation = false on User ID field allows arbitrary user IDs \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ElecVATSetup.Table.al", "line_start": 23, "line_end": 23, "body": "VAT Authority URL field has no HTTPS validation \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseAgentSetup.Table.al", "line_start": 37, "line_end": 37, "body": "Service URL from user-editable table field passed to HttpClient.Get without SSRF validation \u2014 Validate URL using Uri.AreURIsHaveSameHost or Uri.IsValidURIPattern before making HTTP request", "severity": "medium"}, {"file": "src/ElecVATSetup.Table.al", "line_start": 58, "line_end": 58, "body": "Service URL from user-editable table field passed to HttpClient.Post without SSRF validation \u2014 Validate URL using Uri.AreURIsHaveSameHost or Uri.IsValidURIPattern before making HTTP request", "severity": "medium"}], "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": "security-012", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/HttpAuthenticationBasic.Codeunit.al\n+++ src/HttpAuthenticationBasic.Codeunit.al\n+codeunit 2359 \"Http Authentication Basic\"\n+{\n+ Access = Public;\n+ InherentEntitlements = X;\n+ InherentPermissions = X;\n+\n+ var\n+ GlobalUsername: Text;\n+ GlobalPassword: SecretText;\n+ UsernameDomainTxt: Label '%1\\\\%2', Locked = true;\n+\n+ procedure Initialize(Username: SecretText; Password: SecretText)\n+ begin\n+ Initialize(Username, '', Password);\n+ end;\n+\n+ procedure Initialize(Username: SecretText; Domain: Text; Password: SecretText)\n+ begin\n+ if Domain = '' then\n+ GlobalUsername := Username\n+ else\n+ GlobalUsername := StrSubstNo(UsernameDomainTxt, Domain, Username);\n+\n+ GlobalPassword := Password;\n+ end;\n+\n+ procedure IsAuthenticationRequired(): Boolean\n+ begin\n+ exit(true);\n+ end;\n+\n+ local procedure ToBase64(String: SecretText) Base64String: SecretText\n+ var\n+ Convert: DotNet Convert;\n+ Encoding: DotNet Encoding;\n+ begin\n+ Base64String := Convert.ToBase64String(Encoding.UTF8().GetBytes(String.Unwrap()));\n+ end;\n+}", "expected_comments": [{"file": "src/HttpAuthenticationBasic.Codeunit.al", "line_start": 32, "line_end": 32, "body": "Procedure ToBase64 transforms SecretText credential material without [NonDebuggable] \u2014 Add [NonDebuggable] to procedures that receive or process passwords and other SecretText values.", "severity": "medium"}], "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": "security-013", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/AgentAdmin.PermissionSet.al\n+++ src/AgentAdmin.PermissionSet.al\n+permissionset 50700 \"Agent Admin\"\n+{\n+ Assignable = true;\n+ Caption = 'Agent Administration';\n+\n+ Permissions =\n+ tabledata \"Agent Setup\" = RIMD,\n+ tabledata \"Agent Creation Control\" = RIMD,\n+ tabledata \"Agent User Assignment\" = RIMD,\n+ codeunit \"Agent Management\" = X,\n+ page \"Agent Setup Card\" = X;\n+}\n--- src/ExpenseAgentConsumption.Table.al\n+++ src/ExpenseAgentConsumption.Table.al\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(20; \"User Security ID\"; Guid)\n+ {\n+ Caption = 'User Security ID';\n+ DataClassification = EndUserPseudonymousIdentifiers;\n+ TableRelation = User.\"User Security ID\";\n+ }\n+ field(30; \"Agent Operation\"; Enum \"Agent Operation Type\")\n+ {\n+ Caption = 'Agent Operation';\n+ DataClassification = CustomerContent;\n+ }\n+ field(90; \"Response Status\"; Enum \"Agent Response Status\")\n+ {\n+ Caption = 'Response Status';\n+ DataClassification = CustomerContent;\n+ }\n+ field(100; \"Error Message\"; Text[250])\n+ {\n+ Caption = 'Error Message';\n+ DataClassification = CustomerContent;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Entry No.\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ procedure LogConsumption(UserSecurityId: Guid; Operation: Enum \"Agent Operation Type\"; TokenCount: Integer; ProcessingTimeMs: Integer)\n+ var\n+ NewEntry: Record \"Expense Agent Consumption\";\n+ begin\n+ NewEntry.Init();\n+ NewEntry.\"User Security ID\" := UserSecurityId;\n+ NewEntry.\"Agent Operation\" := Operation;\n+ NewEntry.Insert();\n+ end;\n+\n+ procedure LogFailedOperation(UserSecurityId: Guid; Operation: Enum \"Agent Operation Type\"; ErrorText: Text[250])\n+ var\n+ NewEntry: Record \"Expense Agent Consumption\";\n+ begin\n+ NewEntry.Init();\n+ NewEntry.\"User Security ID\" := UserSecurityId;\n+ NewEntry.\"Agent Operation\" := Operation;\n+ NewEntry.\"Response Status\" := NewEntry.\"Response Status\"::Error;\n+ NewEntry.\"Error Message\" := ErrorText;\n+ NewEntry.Insert();\n+ end;\n+}\n--- src/ExpenseMgmtEdit.PermissionSet.al\n+++ src/ExpenseMgmtEdit.PermissionSet.al\n+permissionset 50800 \"ExpenseMgmt Edit\"\n+{\n+ Assignable = true;\n+ Caption = 'Expense Management Edit';\n+\n+ Permissions =\n+ tabledata \"Expense Setup\" = RIM,\n+ tabledata \"Expense User\" = RIMD,\n+ tabledata \"Expense Category\" = RIMD,\n+ tabledata \"Expense Approval Setup\" = RIM,\n+ tabledata \"Expense Report Header\" = RIMD,\n+ tabledata \"Expense Report Line\" = RIMD,\n+ tabledata \"Expense Report Attachment\" = RIMD,\n+ tabledata \"Expense Report Comment\" = RIMD,\n+ tabledata \"Expense Advance\" = RIMD,\n+ tabledata \"Expense Advance Line\" = RIMD,\n+ tabledata \"Expense Mileage\" = RIMD,\n+ tabledata \"Expense Receipt\" = RIMD,\n+ tabledata \"Expense Policy\" = RIM,\n+ tabledata \"Expense Policy Line\" = RIM,\n+ tabledata \"Expense Workflow\" = RIM,\n+ tabledata \"Expense Approval Entry\" = RIM,\n+ tabledata \"Expense Report History\" = R,\n+ tabledata \"Expense Reimbursement\" = RIMD,\n+ tabledata \"Expense Analytics\" = R,\n+ tabledata \"Expense Budget\" = RIMD,\n+ tabledata \"Expense Budget Line\" = RIMD,\n+ tabledata \"Expense Dimension\" = RIMD,\n+ tabledata \"Expense Tax Setup\" = RIM,\n+ tabledata \"Expense Currency Exchange\" = RIM,\n+ tabledata \"Expense Posting Group\" = RIM,\n+ tabledata \"Expense Journal Template\" = RIM,\n+ tabledata \"Expense Journal Batch\" = RIMD,\n+ tabledata \"Expense Journal Line\" = RIMD,\n+ tabledata \"Expense Ledger Entry\" = R,\n+ tabledata \"Expense Register\" = R,\n+ tabledata \"Expense Report Rule\" = RIM,\n+ tabledata \"Expense Report Rule Violation\" = IMD,\n+ codeunit \"Expense Management\" = X,\n+ page \"Expense Report\" = X;\n+}", "expected_comments": [{"file": "src/ExpenseAgentConsumption.Table.al", "line_start": 5, "line_end": 5, "body": "InherentEntitlements = RIX and InherentPermissions = RIX grant overly broad inherent permissions bypassing normal permission checks \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseAgentConsumption.Table.al", "line_start": 52, "line_end": 52, "body": "Public procedure LogConsumption accepts arbitrary UserSecurityId parameter, enabling consumption spoofing for any user \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseAgentConsumption.Table.al", "line_start": 62, "line_end": 62, "body": "Public procedure LogFailedOperation accepts arbitrary UserSecurityId, allowing false error records attributed to any user \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseMgmtEdit.PermissionSet.al", "line_start": 38, "line_end": 38, "body": "IMD permissions on Expense Report Rule Violation table allow users to delete policy violation audit records \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/AgentAdmin.PermissionSet.al", "line_start": 8, "line_end": 8, "body": "RIMD permissions on Agent Creation Control table allow deletion of creation control records bypassing security restrictions \u2014 See agent comment for details.", "severity": "medium"}], "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": "security-014", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/SecureOperationHelper.Codeunit.al\n+++ src/SecureOperationHelper.Codeunit.al\n+codeunit 50105 \"Secure Operation Helper\"\n+{\n+ procedure ArchiveRecord(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": 7, "line_end": 7, "body": "Public procedure uses RecordRef.Open with caller-provided table number \u2014 any extension can access system tables through this codeunit's permissions \u2014 Make the procedure internal or add table whitelist validation before RecordRef.Open", "severity": "high"}], "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": "security-015", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/ExternalIntegrationMgt.Codeunit.al\n+++ src/ExternalIntegrationMgt.Codeunit.al\n+codeunit 50200 \"External Integration Mgt.\"\n+{\n+ Access = Public;\n+\n+ procedure SyncInventoryWithPartner(PartnerSetup: Record \"Partner Integration Setup\")\n+ var\n+ HttpClient: HttpClient;\n+ Content: HttpContent;\n+ Response: HttpResponseMessage;\n+ Item: Record Item;\n+ JsonPayload: JsonObject;\n+ PayloadText: Text;\n+ begin\n+ Item.SetRange(\"Item Category Code\", PartnerSetup.\"Category Filter\");\n+ if Item.FindSet() then\n+ repeat\n+ JsonPayload.Add('itemNo', Item.\"No.\");\n+ JsonPayload.Add('description', Item.Description);\n+ JsonPayload.Add('unitPrice', Item.\"Unit Price\");\n+ JsonPayload.WriteTo(PayloadText);\n+ Content.WriteFrom(PayloadText);\n+ HttpClient.Post(PartnerSetup.\"Sync Endpoint URL\", Content, Response);\n+ Clear(JsonPayload);\n+ until Item.Next() = 0;\n+ end;\n+\n+ procedure FetchExchangeRates(Setup: Record \"Currency Exchange Setup\")\n+ var\n+ HttpClient: HttpClient;\n+ Response: HttpResponseMessage;\n+ ResponseText: Text;\n+ begin\n+ if not HttpClient.Get(Setup.\"Exchange Rate API URL\", Response) then\n+ Error('Failed to connect to exchange rate service');\n+\n+ Response.Content.ReadAs(ResponseText);\n+ ProcessExchangeRateResponse(ResponseText);\n+ end;\n+\n+ procedure SendOrderToFulfillment(SalesHeader: Record \"Sales Header\"; FulfillmentSetup: Record \"Fulfillment Setup\")\n+ var\n+ HttpClient: HttpClient;\n+ Content: HttpContent;\n+ Response: HttpResponseMessage;\n+ OrderJson: Text;\n+ begin\n+ OrderJson := BuildOrderJson(SalesHeader);\n+ Content.WriteFrom(OrderJson);\n+ HttpClient.Post(FulfillmentSetup.\"Fulfillment API URL\", Content, Response);\n+ end;\n+\n+ local procedure BuildOrderJson(SalesHeader: Record \"Sales Header\"): Text\n+ var\n+ JsonObj: JsonObject;\n+ OutputText: Text;\n+ begin\n+ JsonObj.Add('orderNo', SalesHeader.\"No.\");\n+ JsonObj.Add('customerNo', SalesHeader.\"Sell-to Customer No.\");\n+ JsonObj.Add('amount', SalesHeader.Amount);\n+ JsonObj.WriteTo(OutputText);\n+ exit(OutputText);\n+ end;\n+\n+ local procedure ProcessExchangeRateResponse(ResponseText: Text)\n+ begin\n+ // Process exchange rate data\n+ end;\n+}\n+", "expected_comments": [{"file": "src/ExternalIntegrationMgt.Codeunit.al", "line_start": 22, "line_end": 22, "body": "URL from table field used in HttpClient.Post without validation \u2014 SSRF risk \u2014 Validate URL using Uri.AreURIsHaveSameHost or Uri.IsValidURIPattern before making HTTP request", "severity": "high"}, {"file": "src/ExternalIntegrationMgt.Codeunit.al", "line_start": 33, "line_end": 33, "body": "URL from setup table used in HttpClient.Get without host validation \u2014 SSRF risk \u2014 Validate URL host using Uri.AreURIsHaveSameHost before making HTTP request", "severity": "high"}, {"file": "src/ExternalIntegrationMgt.Codeunit.al", "line_start": 49, "line_end": 49, "body": "URL from table field used in HttpClient.Post without validation \u2014 SSRF risk \u2014 Validate URL using Uri.AreURIsHaveSameHost or Uri.IsValidURIPattern before making HTTP request", "severity": "high"}], "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": "security-016", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "--- src/EANotifDispatcher.Codeunit.al\n+++ src/EANotifDispatcher.Codeunit.al\n+codeunit 50900 \"EA Notif Dispatcher\"\n+{\n+ TableNo = \"Expense Notification\";\n+\n+ var\n+ NoEmailConfigErr: Label 'No email configuration found for expense notifications.';\n+\n+ trigger OnRun()\n+ begin\n+ SendExpenseReportNotification(Rec.\"Expense Report No.\", Rec.\"Employee Email\");\n+ end;\n+\n+ procedure SendExpenseReportNotification(ExpenseReportNo: Code[20]; EmployeeEmail: Text)\n+ var\n+ ExpenseReportHeader: Record \"Expense Report Header\";\n+ Recipients: List of [Text];\n+ Subject: Text;\n+ HtmlBody: Text;\n+ EmployeeName: Text;\n+ begin\n+ if not ExpenseReportHeader.Get(ExpenseReportNo) then\n+ Error('Expense report %1 not found.', ExpenseReportNo);\n+\n+ if EmployeeEmail = '' then\n+ Error('No email recipient for expense report %1.', ExpenseReportNo);\n+\n+ Recipients.Add(EmployeeEmail);\n+ EmployeeName := ExpenseReportHeader.\"Employee Name\";\n+ Subject := StrSubstNo('Expense Report %1 Status Update', ExpenseReportNo);\n+\n+ // User-supplied EmployeeName and Description concatenated into HTML without encoding - XSS vulnerability\n+ HtmlBody := '';\n+ HtmlBody += '

Expense Report Update

';\n+ HtmlBody += '

Dear ' + EmployeeName + ',

';\n+ HtmlBody += '

Your expense report has been updated:

';\n+ HtmlBody += '';\n+ HtmlBody += '';\n+ HtmlBody += '';\n+ HtmlBody += '';\n+ HtmlBody += '
Report No.' + ExpenseReportNo + '
Description' + ExpenseReportHeader.Description + '
Status' + Format(ExpenseReportHeader.Status) + '
';\n+ HtmlBody += '

Thank you,
Expense Management System

';\n+ HtmlBody += '';\n+\n+ SendEmailNotification(Recipients, Subject, HtmlBody);\n+ end;\n+\n+ local procedure SendEmailNotification(Recipients: List of [Text]; Subject: Text; HtmlMessage: Text)\n+ var\n+ EmailAccount: Record \"Email Account\";\n+ Email: Codeunit Email;\n+ EmailMessage: Codeunit \"Email Message\";\n+ SendResult: Boolean;\n+ begin\n+ EmailAccount.SetRange(\"Default Account\", true);\n+ if not EmailAccount.FindFirst() then\n+ Error(NoEmailConfigErr);\n+\n+ EmailMessage.Create(Recipients, Subject, HtmlMessage, true);\n+ SendResult := Email.Send(EmailMessage);\n+\n+ if not SendResult then\n+ Error('Failed to send expense notification email.');\n+ end;\n+}\n+", "expected_comments": [{"file": "src/EANotifDispatcher.Codeunit.al", "line_start": 34, "line_end": 34, "body": "EmployeeName embedded in HTML without encoding - XSS vulnerability \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/EANotifDispatcher.Codeunit.al", "line_start": 38, "line_end": 38, "body": "ExpenseReportHeader.Description embedded in HTML without encoding - XSS vulnerability \u2014 See agent comment for details.", "severity": "medium"}], "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": "performance-001", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/FADepreciationBook.Table.al\n+++ src/FADepreciationBook.Table.al\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+ 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('Cannot change half-year convention after depreciation has been posted.');\n+\n+ CalcFields(\"Bonus Depr. Applied Amount\");\n+ if \"Bonus Depr. Applied Amount\" <> 0 then\n+ Error('Cannot change half-year convention when bonus depreciation has been applied.');\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+}\n--- src/SalesOrderCard.Page.al\n+++ src/SalesOrderCard.Page.al\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+ }\n+\n+ field(\"Sell-to Customer No.\"; Rec.\"Sell-to Customer No.\")\n+ {\n+ ApplicationArea = All;\n+ }\n+\n+ field(\"Document Date\"; Rec.\"Document Date\")\n+ {\n+ ApplicationArea = All;\n+ }\n+\n+ field(\"Total Amount\"; TotalAmount)\n+ {\n+ Caption = 'Total Amount Including VAT';\n+ ApplicationArea = All;\n+ Editable = false;\n+ }\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(Processing)\n+ {\n+ action(RefreshTotals)\n+ {\n+ Caption = 'Refresh Totals';\n+ ApplicationArea = All;\n+\n+ trigger OnAction()\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('Total refreshed: %1', 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+}", "expected_comments": [], "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.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-003", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/SetupReader.Codeunit.al\n+++ src/SetupReader.Codeunit.al\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+ begin\n+ // Process additional currency settings\n+ Message('ACY is enabled: %1', GLSetup.\"Additional Reporting Currency\");\n+ end;\n+\n+ local procedure EnableCreditWarnings(SalesSetup: Record \"Sales & Receivables Setup\")\n+ begin\n+ // Enable credit warning processing\n+ Message('Credit warnings are enabled');\n+ end;\n+}\n--- src/TempBufferProcessor.Codeunit.al\n+++ src/TempBufferProcessor.Codeunit.al\n+codeunit 50210 \"Temp Buffer Processor\"\n+{\n+ procedure ProcessBufferEntries(var TempBuffer: Record \"Integer\" temporary)\n+ var\n+ ProcessedCount: Integer;\n+ TotalAmount: Decimal;\n+ begin\n+ // CORRECT: TempBuffer is temporary \u2014 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('Processed %1 entries with total %2', 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+}", "expected_comments": [], "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.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-005", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/MigrationSetup.Codeunit.al\n+++ src/MigrationSetup.Codeunit.al\n+codeunit 50221 \"Migration Setup Handler\"\n+{\n+ // CORRECT: This is a migration codeunit that runs rarely during setup/upgrade\n+ // Migration code doesn't need the same performance optimization as daily business logic\n+\n+ procedure MigrateCompanySettings()\n+ var\n+ CompanyInfo: Record \"Company Information\";\n+ PermissionSet: Record \"Permission Set\";\n+ AccessControl: Record \"Access Control\";\n+ begin\n+ // CORRECT: Migration operations run infrequently with small datasets\n+ // This might look suspicious but is appropriate for migration scenarios\n+\n+ // Set up default permission sets for new company\n+ if PermissionSet.FindSet() then\n+ repeat\n+ // Migration logic: assign basic permission sets to admin users\n+ if IsBasicPermissionSet(PermissionSet.\"Role ID\") then\n+ AssignToAdminUsers(PermissionSet.\"Role ID\");\n+ until PermissionSet.Next() = 0;\n+\n+ // Update company information during migration\n+ if CompanyInfo.Get() then begin\n+ CompanyInfo.\"Allow Blank Payment Info.\" := false;\n+ CompanyInfo.Modify();\n+ end;\n+ end;\n+\n+ procedure CleanupObsoleteData()\n+ var\n+ ObsoleteTable: Record \"Date Compr. Register\";\n+ TempTable: Record \"Date Compr. Register\" temporary;\n+ begin\n+ // CORRECT: Cleanup operations during migration/maintenance\n+ // These operations are performed rarely and on small datasets\n+\n+ // Build list of records to clean up\n+ if ObsoleteTable.FindSet() then\n+ repeat\n+ if IsObsoleteRecord(ObsoleteTable) then begin\n+ TempTable := ObsoleteTable;\n+ TempTable.Insert();\n+ end;\n+ until ObsoleteTable.Next() = 0;\n+\n+ // Delete obsolete records\n+ if TempTable.FindSet() then\n+ repeat\n+ if ObsoleteTable.Get(TempTable.\"No.\") then\n+ ObsoleteTable.Delete();\n+ until TempTable.Next() = 0;\n+ end;\n+\n+ local procedure IsBasicPermissionSet(RoleID: Code[20]): Boolean\n+ begin\n+ exit(RoleID in ['SUPER', 'BASIC', 'READ']);\n+ end;\n+\n+ local procedure AssignToAdminUsers(RoleID: Code[20])\n+ var\n+ User: Record User;\n+ AccessControl: Record \"Access Control\";\n+ begin\n+ // Assign permission set to admin users\n+ User.SetRange(\"License Type\", User.\"License Type\"::\"Full User\");\n+ if User.FindSet() then\n+ repeat\n+ if not AccessControl.Get(User.\"User Security ID\", RoleID, '', Rec.Scope::System, Rec.\"App ID\") then begin\n+ AccessControl.Init();\n+ AccessControl.\"User Security ID\" := User.\"User Security ID\";\n+ AccessControl.\"Role ID\" := RoleID;\n+ AccessControl.Insert();\n+ end;\n+ until User.Next() = 0;\n+ end;\n+\n+ local procedure IsObsoleteRecord(DateComprRegister: Record \"Date Compr. Register\"): Boolean\n+ begin\n+ // Check if record is older than 2 years\n+ exit(DateComprRegister.\"Ending Date\" < CalcDate('<-2Y>', Today));\n+ end;\n+}\n--- src/PermissionSetList.Page.al\n+++ src/PermissionSetList.Page.al\n+page 50220 \"Permission Set List FP\"\n+{\n+ PageType = List;\n+ ApplicationArea = Administration;\n+ UsageCategory = Administration;\n+ SourceTable = \"Aggregate Permission Set\";\n+ Caption = 'Permission Set List (FP Test)';\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(Control1)\n+ {\n+ field(\"Role ID\"; Rec.\"Role ID\")\n+ {\n+ ApplicationArea = All;\n+ }\n+\n+ field(Name; Rec.Name)\n+ {\n+ ApplicationArea = All;\n+ }\n+\n+ field(Scope; Rec.Scope)\n+ {\n+ ApplicationArea = All;\n+ }\n+\n+ field(\"App Name\"; Rec.\"App Name\")\n+ {\n+ ApplicationArea = All;\n+ }\n+\n+ field(\"Permission Count\"; PermissionCount)\n+ {\n+ Caption = 'Permission Count';\n+ ApplicationArea = All;\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+\n+ trigger OnAction()\n+ begin\n+ CurrPage.Update();\n+ end;\n+ }\n+ }\n+ }\n+\n+ var\n+ PermissionCount: Integer;\n+\n+ // CORRECT: This is an admin-only page accessed infrequently\n+ // Permission sets are a small, bounded dataset (typically <100 records)\n+ // Admin pages don't need the same performance optimization as end-user pages\n+ trigger OnAfterGetRecord()\n+ var\n+ Permission: Record Permission;\n+ begin\n+ // Calculate permission count for display\n+ // This looks suspicious but is CORRECT because:\n+ // 1. Admin-only functionality - not a high-traffic page\n+ // 2. Small dataset - permission sets are typically <100 records\n+ // 3. Used infrequently for administrative purposes\n+ Permission.SetRange(\"Role ID\", Rec.\"Role ID\");\n+ PermissionCount := Permission.Count();\n+ end;\n+\n+ trigger OnOpenPage()\n+ begin\n+ // CORRECT: Filter to show only system and user permission sets\n+ Rec.SetFilter(Scope, '%1|%2', Rec.Scope::System, Rec.Scope::Tenant);\n+ end;\n+}", "expected_comments": [], "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.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-007", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/BatchModifier.Codeunit.al\n+++ src/BatchModifier.Codeunit.al\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 \u2014 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+}", "expected_comments": [], "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.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-009", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/EnumIterator.Codeunit.al\n+++ src/EnumIterator.Codeunit.al\n+codeunit 50240 \"Enum Iterator FP\"\n+{\n+ procedure ProcessByStatus(var TempWorksheet: Record \"Integer\" temporary)\n+ var\n+ StatusType: Record \"Analysis Report Name\"; // Small lookup table for status values\n+ Counter: Integer;\n+ begin\n+ // CORRECT: Outer loop on temp table, inner lookup on small bounded table\n+ // StatusType represents a small enum-like table (typically <20 records)\n+ Counter := 0;\n+\n+ if TempWorksheet.FindSet() then\n+ repeat\n+ Counter += 1;\n+\n+ // CORRECT: StatusType is a small configuration/enum table\n+ // These tables are bounded (rarely >50 records) and frequently cached\n+ StatusType.SetRange(\"Analysis Area\", StatusType.\"Analysis Area\"::Sales);\n+ if StatusType.Get(Format(TempWorksheet.Number)) then begin\n+ TempWorksheet.Number := Counter * 100; // Update with processed status\n+ TempWorksheet.Modify();\n+ end;\n+ until TempWorksheet.Next() = 0;\n+ end;\n+\n+ procedure ValidateWorksheetLines(var TempBuffer: Record \"Name/Value Buffer\" temporary)\n+ var\n+ ConfigTable: Record \"Config. Package Table\"; // Small configuration table\n+ begin\n+ // CORRECT: Configuration tables are small and bounded\n+ // Config tables contain setup data, not transactional data\n+\n+ if TempBuffer.FindSet() then\n+ repeat\n+ // Look up configuration for this line type\n+ ConfigTable.SetRange(\"Package Code\", 'SETUP');\n+ ConfigTable.SetRange(\"Table Name\", TempBuffer.Name);\n+\n+ if ConfigTable.FindFirst() then begin\n+ // Validate against configuration rules\n+ if ConfigTable.\"Skip Table Triggers\" then\n+ TempBuffer.Value := 'VALID'\n+ else\n+ TempBuffer.Value := 'NEEDS_REVIEW';\n+\n+ TempBuffer.Modify();\n+ end;\n+ until TempBuffer.Next() = 0;\n+ end;\n+\n+ procedure ProcessCountryCodes(var TempCodes: Record \"Country/Region\" temporary)\n+ var\n+ Currency: Record Currency; // Small master data table\n+ begin\n+ // CORRECT: Currency is a small master data table (typically <100 records)\n+ // Master data tables like Currency, Unit of Measure are small and stable\n+\n+ if TempCodes.FindSet() then\n+ repeat\n+ // Look up default currency for country\n+ Currency.SetRange(\"ISO Code\", TempCodes.\"ISO Code\");\n+ if Currency.FindFirst() then begin\n+ TempCodes.Name := TempCodes.Name + ' (' + Currency.Code + ')';\n+ TempCodes.Modify();\n+ end;\n+ until TempCodes.Next() = 0;\n+ end;\n+}\n--- src/RoleProcessor.Codeunit.al\n+++ src/RoleProcessor.Codeunit.al\n+codeunit 50241 \"Role Processor FP\"\n+{\n+ procedure AssignDefaultRoles(UserSecurityId: Guid)\n+ var\n+ DefaultRole: Record \"Permission Set\"; // Small system table for role definitions\n+ AccessControl: Record \"Access Control\";\n+ RoleCount: Integer;\n+ begin\n+ // CORRECT: Permission sets are a small, bounded configuration table\n+ // Typical installations have <100 permission sets, often <50\n+ // This is system configuration data, not transactional data\n+\n+ DefaultRole.SetRange(Type, DefaultRole.Type::\"User-Defined\");\n+ DefaultRole.SetFilter(\"Role ID\", 'BASIC|USER|READ'); // Only basic roles\n+\n+ RoleCount := 0;\n+ if DefaultRole.FindSet() then\n+ repeat\n+ // Assign this role to the user if not already assigned\n+ AccessControl.SetRange(\"User Security ID\", UserSecurityId);\n+ AccessControl.SetRange(\"Role ID\", DefaultRole.\"Role ID\");\n+\n+ if AccessControl.IsEmpty then begin\n+ AccessControl.Init();\n+ AccessControl.\"User Security ID\" := UserSecurityId;\n+ AccessControl.\"Role ID\" := DefaultRole.\"Role ID\";\n+ AccessControl.Scope := AccessControl.Scope::System;\n+ if AccessControl.Insert(false) then\n+ RoleCount += 1;\n+ end;\n+ until DefaultRole.Next() = 0;\n+\n+ Message('Assigned %1 default roles to user', RoleCount);\n+ end;\n+\n+ procedure SetupDepartmentAccess(DepartmentCode: Code[10])\n+ var\n+ UserSetup: Record \"User Setup\"; // Small user configuration table\n+ DimensionValue: Record \"Dimension Value\"; // Bounded by dimension setup\n+ begin\n+ // CORRECT: Both tables are small and bounded:\n+ // - UserSetup: One record per user (typically <100 users)\n+ // - DimensionValue for Department: Small set of department codes (<50)\n+\n+ DimensionValue.SetRange(\"Dimension Code\", 'DEPARTMENT');\n+ DimensionValue.SetRange(Code, DepartmentCode);\n+\n+ if DimensionValue.FindFirst() and UserSetup.FindSet() then\n+ repeat\n+ // Grant department access to all users in this role\n+ if UserSetup.\"Salespers./Purch. Code\" <> '' then begin\n+ // Logic to assign department access\n+ UserSetup.\"Global Dimension 1 Code\" := DepartmentCode;\n+ UserSetup.Modify();\n+ end;\n+ until UserSetup.Next() = 0;\n+ end;\n+\n+ procedure ConfigureNewCompanyRoles()\n+ var\n+ PermissionSet: Record \"Permission Set\";\n+ User: Record User; // Small table in typical installations\n+ DefaultUserRole: Code[20];\n+ begin\n+ // CORRECT: User table is small (typically <100 users even in large companies)\n+ // This is setup/configuration code that runs during company initialization\n+\n+ DefaultUserRole := 'D365 BUS FULL ACCESS'; // Default role for new company\n+\n+ PermissionSet.SetRange(\"Role ID\", DefaultUserRole);\n+ if PermissionSet.FindFirst() then begin\n+ User.SetRange(\"License Type\", User.\"License Type\"::\"Full User\");\n+ User.SetRange(State, User.State::Enabled);\n+\n+ if User.FindSet() then\n+ repeat\n+ // Assign default role to enabled full users\n+ AssignRoleToUser(User.\"User Security ID\", DefaultUserRole);\n+ until User.Next() = 0;\n+ end;\n+ end;\n+\n+ local procedure AssignRoleToUser(UserSecurityId: Guid; RoleID: Code[20])\n+ var\n+ AccessControl: Record \"Access Control\";\n+ begin\n+ AccessControl.Init();\n+ AccessControl.\"User Security ID\" := UserSecurityId;\n+ AccessControl.\"Role ID\" := RoleID;\n+ AccessControl.Scope := AccessControl.Scope::System;\n+ AccessControl.Insert(false);\n+ end;\n+}", "expected_comments": [], "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.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-011", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/GenericFieldCopier.Codeunit.al\n+++ src/GenericFieldCopier.Codeunit.al\n+codeunit 50250 \"Generic Field Copier FP\"\n+{\n+ procedure CopyFieldsByRef(SourceRecRef: RecordRef; var DestRecRef: RecordRef)\n+ var\n+ SourceFldRef: FieldRef;\n+ DestFldRef: FieldRef;\n+ i: Integer;\n+ begin\n+ // CORRECT: RecordRef is REQUIRED for generic field copying between unknown table types\n+ // This is the only way to copy fields when table structure is unknown at compile time\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 CopyFilteredRecords(SourceTableNo: Integer; DestTableNo: Integer; FilterFieldNo: Integer; FilterValue: Text)\n+ var\n+ SourceRecRef: RecordRef;\n+ DestRecRef: RecordRef;\n+ begin\n+ // CORRECT: RecordRef with filters applied \u2014 bounded iteration over filtered set\n+ SourceRecRef.Open(SourceTableNo);\n+ SourceRecRef.Field(FilterFieldNo).SetFilter(FilterValue);\n+ DestRecRef.Open(DestTableNo);\n+\n+ if SourceRecRef.FindSet() then\n+ repeat\n+ DestRecRef.Init();\n+ CopyFieldsByRef(SourceRecRef, DestRecRef);\n+ DestRecRef.Insert(false);\n+ until SourceRecRef.Next() = 0;\n+\n+ SourceRecRef.Close();\n+ DestRecRef.Close();\n+ end;\n+}\n--- src/MetadataReader.Codeunit.al\n+++ src/MetadataReader.Codeunit.al\n+codeunit 50251 \"Metadata Reader FP\"\n+{\n+ procedure GetTableNames(): List of [Text]\n+ var\n+ TableMetadata: Record \"Table Metadata\";\n+ Names: List of [Text];\n+ begin\n+ // CORRECT: TableMetadata is a bounded system table with a small, fixed dataset\n+ // Metadata tables contain system information, not transactional data\n+ // Typical installations have <2000 tables, which is a small, bounded set\n+\n+ TableMetadata.SetRange(TableType, TableMetadata.TableType::Normal);\n+ TableMetadata.SetFilter(ID, '1..99999999'); // User tables\n+\n+ if TableMetadata.FindSet() then\n+ repeat\n+ Names.Add(TableMetadata.Name);\n+ until TableMetadata.Next() = 0;\n+\n+ exit(Names);\n+ end;\n+\n+ procedure GetFieldInfo(TableNo: Integer): List of [Text]\n+ var\n+ FieldMetadata: Record Field;\n+ FieldInfo: List of [Text];\n+ InfoText: Text;\n+ begin\n+ // CORRECT: Field metadata for a single table is bounded (typically <100 fields per table)\n+ // This is system metadata, not user data - bounded and relatively static\n+\n+ FieldMetadata.SetRange(TableNo, TableNo);\n+ FieldMetadata.SetRange(Class, FieldMetadata.Class::Normal);\n+\n+ if FieldMetadata.FindSet() then\n+ repeat\n+ InfoText := StrSubstNo('%1: %2 (%3)', FieldMetadata.\"No.\", FieldMetadata.FieldName, FieldMetadata.Type);\n+ FieldInfo.Add(InfoText);\n+ until FieldMetadata.Next() = 0;\n+\n+ exit(FieldInfo);\n+ end;\n+\n+ procedure GetExtensionTables(): List of [Integer]\n+ var\n+ TableMetadata: Record \"Table Metadata\";\n+ ExtensionTables: List of [Integer];\n+ begin\n+ // CORRECT: Filtering for extension tables - still a bounded, small set\n+ // Extension tables are a subset of all tables, making this even more bounded\n+\n+ TableMetadata.SetFilter(ID, '50000..99999'); // Extension table range\n+ TableMetadata.SetRange(TableType, TableMetadata.TableType::Normal);\n+\n+ if TableMetadata.FindSet() then\n+ repeat\n+ ExtensionTables.Add(TableMetadata.ID);\n+ until TableMetadata.Next() = 0;\n+\n+ exit(ExtensionTables);\n+ end;\n+\n+ procedure CheckTableExists(TableNo: Integer; TableName: Text): Boolean\n+ var\n+ TableMetadata: Record \"Table Metadata\";\n+ begin\n+ // CORRECT: Single record lookup in metadata table - very fast operation\n+ exit(TableMetadata.Get(TableNo) and (TableMetadata.Name = TableName));\n+ end;\n+\n+ procedure GetSystemTableInfo(): Dictionary of [Integer, Text]\n+ var\n+ TableMetadata: Record \"Table Metadata\";\n+ SystemTables: Dictionary of [Integer, Text];\n+ begin\n+ // CORRECT: System tables are a small, bounded subset (<100 system tables)\n+ TableMetadata.SetFilter(ID, '1..10000'); // System table range\n+ TableMetadata.SetRange(TableType, TableMetadata.TableType::Normal);\n+\n+ if TableMetadata.FindSet() then\n+ repeat\n+ SystemTables.Add(TableMetadata.ID, TableMetadata.Name);\n+ until TableMetadata.Next() = 0;\n+\n+ exit(SystemTables);\n+ end;\n+}", "expected_comments": [], "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.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-012", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/TransactionProcessor.Codeunit.al\n+++ src/TransactionProcessor.Codeunit.al\n+codeunit 50403 \"Transaction Processor\"\n+{\n+ Access = Public;\n+\n+ procedure UpdatePaymentTerms(TermsCode: Code[10]; NewDiscount: Decimal)\n+ var\n+ PaymentTerms: Record \"Payment Terms\";\n+ begin\n+ if PaymentTerms.Get(TermsCode) then begin\n+ PaymentTerms.\"Discount %\" := NewDiscount;\n+ PaymentTerms.Modify(false);\n+ end;\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+}\n+", "expected_comments": [], "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.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-014", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/CopyLocationHandler.Codeunit.al\n+++ src/CopyLocationHandler.Codeunit.al\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 \u2014 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) \u2014 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 \u2014 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+}\n--- src/SetupValidator.Codeunit.al\n+++ src/SetupValidator.Codeunit.al\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+}", "expected_comments": [], "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.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-016", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/TempAPIPage.Page.al\n+++ src/TempAPIPage.Page.al\n+page 50271 \"Temp API Page FP\"\n+{\n+ PageType = API;\n+ APIPublisher = 'microsoft';\n+ APIGroup = 'testGroup';\n+ APIVersion = 'v1.0';\n+ EntityName = 'tempRecord';\n+ EntitySetName = 'tempRecords';\n+ SourceTable = \"Name/Value Buffer\";\n+ SourceTableTemporary = true; // CORRECT: This makes all operations in-memory\n+ DelayedInsert = true;\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(GroupName)\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+ // CORRECT: All triggers operate on temp data (in-memory)\n+ // SourceTableTemporary = true ensures no database operations\n+\n+ trigger OnOpenPage()\n+ var\n+ Counter: Integer;\n+ begin\n+ // CORRECT: Initialize temp data for API\n+ Rec.DeleteAll();\n+\n+ for Counter := 1 to 10 do begin\n+ Rec.Init();\n+ Rec.ID := Counter;\n+ Rec.Name := 'Item ' + Format(Counter);\n+ Rec.Value := Format(Counter * 100);\n+ Rec.Insert();\n+ end;\n+ end;\n+\n+ trigger OnInsertRecord(BelowxRec: Boolean): Boolean\n+ var\n+ NextID: Integer;\n+ begin\n+ // CORRECT: Temp table operations for API insert\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+ // CORRECT: Validation on temp record modification\n+ if Rec.Name = '' then\n+ Error('Name cannot be empty');\n+\n+ if Rec.Value = '' then\n+ Rec.Value := 'DEFAULT';\n+\n+ exit(true);\n+ end;\n+\n+ trigger OnDeleteRecord(): Boolean\n+ begin\n+ // CORRECT: Allow deletion in temp table API\n+ exit(true);\n+ end;\n+}\n--- src/TempTableCalculator.Codeunit.al\n+++ src/TempTableCalculator.Codeunit.al\n+codeunit 50270 \"Temp Table Calculator FP\"\n+{\n+ procedure CalculateTotals(var TempLine: Record \"Sales Line\" temporary)\n+ var\n+ Total: Decimal;\n+ LineCount: Integer;\n+ begin\n+ // CORRECT: All operations on temp table are in-memory and performant\n+ // Temporary tables don't hit the database, so any access pattern is fine\n+\n+ TempLine.Reset();\n+ Total := 0;\n+ LineCount := 0;\n+\n+ if TempLine.FindSet() then\n+ repeat\n+ LineCount += 1;\n+\n+ // CORRECT: CalcFields on temp table is in-memory calculation\n+ TempLine.CalcFields(\"Outstanding Amount\");\n+ Total += TempLine.\"Outstanding Amount\";\n+\n+ // Update running total on each line\n+ TempLine.\"Line Amount\" := Total;\n+ TempLine.Modify();\n+\n+ until TempLine.Next() = 0;\n+\n+ Message('Processed %1 lines with total %2', LineCount, Total);\n+ end;\n+\n+ procedure ProcessTempData(var TempItem: Record Item temporary)\n+ var\n+ ProcessedCount: Integer;\n+ MaxUnitPrice: Decimal;\n+ begin\n+ // CORRECT: Complex processing on temp table - all in-memory operations\n+ ProcessedCount := 0;\n+ MaxUnitPrice := 0;\n+\n+ // First pass: find maximum unit price\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+\n+ // Second pass: update items based on max price\n+ TempItem.Reset();\n+ if TempItem.FindSet(true) then\n+ repeat\n+ if TempItem.\"Unit Price\" = MaxUnitPrice then\n+ TempItem.Description := TempItem.Description + ' (PREMIUM)';\n+\n+ ProcessedCount += 1;\n+ TempItem.Modify();\n+ until TempItem.Next() = 0;\n+\n+ Message('Updated %1 items, max price: %2', ProcessedCount, MaxUnitPrice);\n+ end;\n+\n+ procedure BuildAnalysisData(var TempBuffer: Record \"Analysis Report Chart Setup\" temporary)\n+ var\n+ Counter: Integer;\n+ i: Integer;\n+ begin\n+ // CORRECT: Building and manipulating temp data - all in-memory\n+ TempBuffer.DeleteAll();\n+ Counter := 0;\n+\n+ for i := 1 to 50 do begin\n+ TempBuffer.Init();\n+ TempBuffer.\"User ID\" := UserId;\n+ TempBuffer.\"Analysis Report Name\" := 'TEMP_ANALYSIS_' + Format(i);\n+ TempBuffer.\"Analysis Line Template Name\" := 'DEFAULT';\n+ TempBuffer.\"Analysis Column Template Name\" := 'DEFAULT';\n+ TempBuffer.Insert();\n+ Counter += 1;\n+ end;\n+\n+ // Process the temp data\n+ if TempBuffer.FindSet() then\n+ repeat\n+ TempBuffer.\"Chart Type\" := TempBuffer.\"Chart Type\"::Column;\n+ TempBuffer.Modify();\n+ until TempBuffer.Next() = 0;\n+\n+ Message('Built %1 analysis entries', Counter);\n+ end;\n+}", "expected_comments": [], "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.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-018", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/PaymentToleranceMgt.Codeunit.al\n+++ src/PaymentToleranceMgt.Codeunit.al\n+codeunit 50101 \"Payment Tolerance Mgt.\"\n+{\n+ Access = Public;\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+ // BAD: Loop + Modify to update a single field \u2014 should use ModifyAll\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+}", "expected_comments": [{"file": "src/PaymentToleranceMgt.Codeunit.al", "line_start": 20, "line_end": 20, "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. \u2014 Replace the FindSet() + Modify loop with SalesLine.ModifyAll(\"Unit Price\", NewPrice).", "severity": "medium"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: bulk_operations \u2014 loop + Modify anti-pattern that should use ModifyAll.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-019", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/AgentTaskViewer.Page.al\n+++ src/AgentTaskViewer.Page.al\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; }\n+ field(Status; Rec.Status) { ApplicationArea = All; }\n+ field(InputPreview; InputPreview) { ApplicationArea = All; }\n+ field(OutputPreview; OutputPreview) { ApplicationArea = All; }\n+ }\n+ }\n+ }\n+ trigger OnAfterGetRecord()\n+ begin\n+ // Performance issue: CalcFields on BLOB fields per row is very expensive\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+}\n--- src/BillingOverview.Page.al\n+++ src/BillingOverview.Page.al\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; }\n+ field(\"Sell-to Customer Name\"; Rec.\"Sell-to Customer Name\") { ApplicationArea = All; }\n+ field(Amount; Rec.Amount) { ApplicationArea = All; }\n+ field(\"Amount Including VAT\"; Rec.\"Amount Including VAT\") { ApplicationArea = All; }\n+ field(\"Remaining Amount\"; Rec.\"Remaining Amount\") { ApplicationArea = All; }\n+ }\n+ }\n+ }\n+ trigger OnAfterGetRecord()\n+ begin\n+ // Performance issue: 3 CalcFields per row on 300k+ Sales Invoice Headers\n+ Rec.CalcFields(Amount);\n+ Rec.CalcFields(\"Amount Including VAT\");\n+ Rec.CalcFields(\"Remaining Amount\");\n+ end;\n+}\n--- src/WarehousePickProcessor.Codeunit.al\n+++ src/WarehousePickProcessor.Codeunit.al\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+ // Performance issue: CalcFields FlowField in loop \u2014 N separate SQL queries\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 Line: Record \"Warehouse Activity Line\")\n+ begin\n+ end;\n+}", "expected_comments": [{"file": "src/AgentTaskViewer.Page.al", "line_start": 21, "line_end": 21, "body": "CalcFields on BLOB fields in OnAfterGetRecord is expensive and fires per row. Consider loading BLOBs only when needed or use streaming. \u2014 ", "severity": "low"}, {"file": "src/BillingOverview.Page.al", "line_start": 22, "line_end": 22, "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. \u2014 ", "severity": "low"}, {"file": "src/WarehousePickProcessor.Codeunit.al", "line_start": 12, "line_end": 12, "body": "CalcFields inside repeat loop creates separate database queries for each record. Use SetLoadFields before FindSet or batch CalcFields operations. \u2014 ", "severity": "low"}], "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": "performance-020", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/JournalPostConfirm.Codeunit.al\n+++ src/JournalPostConfirm.Codeunit.al\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+ // Performance issue: Confirm inside loop holds locks while waiting for user\n+ if Confirm('Post line %1 for amount %2?', 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--- src/UpgradeProcessor.Codeunit.al\n+++ src/UpgradeProcessor.Codeunit.al\n+codeunit 50330 \"Upgrade Processor\"\n+{\n+ procedure UpgradeServiceRegisters()\n+ var\n+ ServiceRegister: Record \"Service Register\";\n+ UpgradeTag: Codeunit \"Upgrade Tag\";\n+ begin\n+ if UpgradeTag.HasUpgradeTag(GetUpgradeTag()) then\n+ exit;\n+\n+ if ServiceRegister.FindSet(true) then\n+ repeat\n+ ServiceRegister.\"Upgraded\" := true;\n+ ServiceRegister.Modify(false);\n+ // Performance issue: Commit per record creates N transaction boundaries\n+ Commit();\n+ until ServiceRegister.Next() = 0;\n+\n+ UpgradeTag.SetUpgradeTag(GetUpgradeTag());\n+ end;\n+\n+ local procedure GetUpgradeTag(): Code[250]\n+ begin\n+ exit('MS-12345-UpgradeServiceRegisters-20260401');\n+ end;\n+}", "expected_comments": [{"file": "src/JournalPostConfirm.Codeunit.al", "line_start": 10, "line_end": 10, "body": "Confirm dialog inside loop holds database locks while waiting for user input. Move confirmation outside loop or batch user decisions. \u2014 ", "severity": "low"}, {"file": "src/UpgradeProcessor.Codeunit.al", "line_start": 16, "line_end": 16, "body": "Commit() inside loop creates excessive transaction boundaries and reduces batch performance. Consider committing in batches or after loop completion. \u2014 ", "severity": "low"}], "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": "performance-021", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/AgentActivities.Page.al\n+++ src/AgentActivities.Page.al\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'; }\n+ field(ProcessedToday; ProcessedTodayCount) { ApplicationArea = All; Caption = '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+}\n--- src/KPICalculator.Codeunit.al\n+++ src/KPICalculator.Codeunit.al\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+}", "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 \u2014 it stops at the first record found. \u2014 ", "severity": "low"}, {"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. \u2014 ", "severity": "low"}], "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": "performance-022", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/DataProcessor.Codeunit.al\n+++ src/DataProcessor.Codeunit.al\n+codeunit 50402 \"Data Processor\"\n+{\n+ Access = Public;\n+\n+ procedure CalculateTotalsWithTempTable(var SalesLine: Record \"Sales Line\")\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+ end;\n+\n+ procedure CalculateTotalsWithDictionary(var SalesLine: Record \"Sales Line\")\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+ 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+ else\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. \u2014 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"}], "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": "performance-023", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/ContactMgt.Codeunit.al\n+++ src/ContactMgt.Codeunit.al\n+codeunit 50110 \"Contact Management\"\n+{\n+ Access = Public;\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+}\n--- src/InstallAppCZL.Codeunit.al\n+++ src/InstallAppCZL.Codeunit.al\n+codeunit 50111 \"Install Application CZL\"\n+{\n+ Access = Public;\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+}\n--- src/ReservationMgt.Codeunit.al\n+++ src/ReservationMgt.Codeunit.al\n+codeunit 50112 \"Reservation Priority Mgt.\"\n+{\n+ Access = Public;\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('Allocate all open reservations (%1 records)?', 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('Successfully allocated %1 reservations.', ProcessedCount);\n+ end;\n+\n+ local procedure UpdateProgressDialog(Processed: Integer; Total: Integer)\n+ begin\n+ // Update progress indicator\n+ end;\n+}", "expected_comments": [{"file": "src/ContactMgt.Codeunit.al", "line_start": 15, "line_end": 15, "body": "FindFirst() used for existence check when IsEmpty() would suffice \u2014 See agent review for details.", "severity": "medium"}, {"file": "src/InstallAppCZL.Codeunit.al", "line_start": 18, "line_end": 18, "body": "FindSet() without true parameter but modifies records in loop \u2014 See agent review for details.", "severity": "medium"}, {"file": "src/ReservationMgt.Codeunit.al", "line_start": 20, "line_end": 20, "body": "Confirm() dialog after FindSet(true) holds locks \u2014 See agent review for details.", "severity": "medium"}], "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": "performance-024", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/ExpenseReportHeader.Table.al\n+++ src/ExpenseReportHeader.Table.al\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+ }\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+ // WARNING: No SIFT key exists on \"Expense Report Line\" for (Document No., Refundable) with SumIndexField Amount.\n+ // This causes full table scans on every CalcFields call.\n+ field(10; \"Refundable Amount\"; Decimal)\n+ {\n+ FieldClass = FlowField;\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+ 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+}", "expected_comments": [{"file": "src/ExpenseReportHeader.Table.al", "line_start": 48, "line_end": 48, "body": "FlowField CalcFormula uses SUM on Expense Report Line without a matching SIFT key, causing table scans on large datasets \u2014 See agent review for details.", "severity": "medium"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: index_usage \u2014 FlowField CalcFormula missing SIFT key on source table", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-025", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/AgentStatus.Table.al\n+++ src/AgentStatus.Table.al\n+table 50130 \"EA Agent Status\"\n+{\n+ Caption = 'EA Agent 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; Option)\n+ {\n+ Caption = 'Status';\n+ OptionMembers = Idle,Running,Error;\n+ }\n+ field(5; \"Last Error Message\"; Text[250])\n+ {\n+ Caption = 'Last Error Message';\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: Option; 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+}\n--- src/VendEntryEditHandler.Codeunit.al\n+++ src/VendEntryEditHandler.Codeunit.al\n+codeunit 50131 \"Vend. Entry Edit Handler\"\n+{\n+ Access = Public;\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('Maximum recursion level exceeded for entry %1', 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+ LogEntryUpdate(EntryNo, Level);\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+ local procedure LogEntryUpdate(EntryNo: Integer; Level: Integer)\n+ begin\n+ // Log the update for audit purposes\n+ end;\n+}", "expected_comments": [{"file": "src/AgentStatus.Table.al", "line_start": 46, "line_end": 46, "body": "GetOrCreate() unconditionally locks even for readers \u2014 See agent review for details.", "severity": "medium"}, {"file": "src/VendEntryEditHandler.Codeunit.al", "line_start": 16, "line_end": 16, "body": "ReadIsolation UpdLock inside recursive function \u2014 See agent review for details.", "severity": "medium"}], "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": "performance-026", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/BOMBufferMgt.Codeunit.al\n+++ src/BOMBufferMgt.Codeunit.al\n+codeunit 50142 \"BOM Buffer Management\"\n+{\n+ Access = Public;\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+ // Bad: N+1 \u2014 Item.Get for every BOM buffer record\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+}\n--- src/PurchRcptLineMgt.Codeunit.al\n+++ src/PurchRcptLineMgt.Codeunit.al\n+codeunit 50140 \"Purch. Rcpt. Line Mgmt.\"\n+{\n+ Access = Public;\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+ // Bad: CalcFields on FlowField inside a loop \u2014 N separate SQL queries\n+ PurchRcptLine.CalcFields(\"Currency Code\");\n+\n+ if PurchRcptLine.\"Currency Code\" <> '' then begin\n+ ProcessForeignCurrencyLine(PurchRcptLine);\n+ ProcessedCount += 1;\n+ end else begin\n+ ProcessLocalCurrencyLine(PurchRcptLine);\n+ end;\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+}\n--- src/ReportLayoutsMgt.Page.al\n+++ src/ReportLayoutsMgt.Page.al\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+ // Bad: N+1 \u2014 User.Get called per row displayed on list page\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 := 'Unknown User';\n+ end;\n+\n+ var\n+ UserDisplayName: Text[80];\n+}", "expected_comments": [{"file": "src/BOMBufferMgt.Codeunit.al", "line_start": 24, "line_end": 24, "body": "N+1 Item.Get per BOM line \u2014 See agent review for details.", "severity": "medium"}, {"file": "src/PurchRcptLineMgt.Codeunit.al", "line_start": 25, "line_end": 25, "body": "CalcFields on FlowField inside a loop \u2014 See agent review for details.", "severity": "medium"}, {"file": "src/ReportLayoutsMgt.Page.al", "line_start": 53, "line_end": 53, "body": "N+1 User.Get() in OnAfterGetRecord \u2014 See agent review for details.", "severity": "medium"}], "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": "performance-027", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/RequisitionWkshName.Table.al\n+++ src/RequisitionWkshName.Table.al\n+table 50151 \"Requisition Wksh. Name Copy\"\n+{\n+ Caption = 'Requisition Wksh. Name Copy';\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+ }\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+ 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+ 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+ }\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+}", "expected_comments": [{"file": "src/RequisitionWkshName.Table.al", "line_start": 74, "line_end": 74, "body": "CalcFields on SUM FlowFields over Requisition Line can table-scan without a supporting SIFT path \u2014 See agent review for details.", "severity": "medium"}, {"file": "src/RequisitionWkshName.Table.al", "line_start": 81, "line_end": 81, "body": "CalcFields on SUM FlowFields over Requisition Line can table-scan without a supporting SIFT path \u2014 See agent review for details.", "severity": "medium"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: other_performance \u2014 SUM FlowFields without SIFT indexes", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-028", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/AssemblyOrderSubform.Page.al\n+++ src/AssemblyOrderSubform.Page.al\n+page 50160 \"Assembly Order Subform Copy\"\n+{\n+ Caption = 'Assembly Order Subform Copy';\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+}\n--- src/ItemLedgerEntryAPAC.Table.al\n+++ src/ItemLedgerEntryAPAC.Table.al\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+ }\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+ // Bad: SumIndexFields removed, replaced with IncludedFields \u2014 breaks SIFT on 10M+ row table\n+ key(Key7; \"Location Code\", \"Item No.\", \"Variant Code\", Open, Positive)\n+ {\n+ IncludedFields = Quantity, \"Remaining Quantity\";\n+ // Was: SumIndexFields = 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+}", "expected_comments": [{"file": "src/AssemblyOrderSubform.Page.al", "line_start": 70, "line_end": 70, "body": "Modify in OnAfterGetRecord \u2014 See agent review for details.", "severity": "medium"}, {"file": "src/ItemLedgerEntryAPAC.Table.al", "line_start": 96, "line_end": 96, "body": "SumIndexFields changed to IncludedFields \u2014 See agent review for details.", "severity": "medium"}], "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": "performance-029", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/ConfigurationHelper.Codeunit.al\n+++ src/ConfigurationHelper.Codeunit.al\n+codeunit 50400 \"Configuration Helper\"\n+{\n+ Access = Public;\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+ else\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+ else\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+ else\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+ else\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+ else\n+ exit(false);\n+ end;\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. \u2014 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"}, {"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. \u2014 Remove the LockTable() call or use ReadIsolation := IsolationLevel::ReadCommitted since this procedure only reads data for display purposes.", "severity": "medium"}], "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": "performance-030", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/AssemblyLineMgt.Codeunit.al\n+++ src/AssemblyLineMgt.Codeunit.al\n+codeunit 50172 \"Assembly Line Check\"\n+{\n+ Access = Public;\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('Quantity must be greater than zero');\n+\n+ if AssemblyLine.Type = AssemblyLine.Type::Item then\n+ if not CheckAvailability(AssemblyLine) then\n+ Message('Insufficient inventory available for item %1', AssemblyLine.\"No.\");\n+ end;\n+}\n--- src/PurchAllocAccMgt.Codeunit.al\n+++ src/PurchAllocAccMgt.Codeunit.al\n+codeunit 50170 \"Purchase Alloc. Acc. Mgt.\"\n+{\n+ Access = Public;\n+\n+ procedure DistributeAmount(PurchaseLine: Record \"Purchase Line\")\n+ var\n+ PurchaseHeader: Record \"Purchase Header\";\n+ AllocationAccount: Record \"Allocation Account\";\n+ begin\n+ // Bad: Get before guard \u2014 wasted DB call when allocation not set\n+ PurchaseHeader.Get(PurchaseLine.\"Document Type\", PurchaseLine.\"Document No.\");\n+\n+ // Guard condition - should be checked first\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('Allocation account %1 does not exist.', PurchaseLine.\"Selected Alloc. Account No.\");\n+\n+ // Validate account is active\n+ AllocationAccount.TestField(Blocked, false);\n+\n+ // Perform the distribution\n+ DistributeToAllocAccount(PurchaseHeader, 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(PurchaseHeader: Record \"Purchase Header\"; 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--- src/SKUMgt.Codeunit.al\n+++ src/SKUMgt.Codeunit.al\n+codeunit 50171 \"SKU Management\"\n+{\n+ Access = Public;\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('SKU creation is not allowed for location %1', 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+}", "expected_comments": [{"file": "src/AssemblyLineMgt.Codeunit.al", "line_start": 9, "line_end": 9, "body": "Redundant Get in OnAfterGetRecord context \u2014 See agent review for details.", "severity": "medium"}, {"file": "src/PurchAllocAccMgt.Codeunit.al", "line_start": 11, "line_end": 11, "body": "Get() before guard condition \u2014 See agent review for details.", "severity": "medium"}, {"file": "src/SKUMgt.Codeunit.al", "line_start": 12, "line_end": 12, "body": "Get() without SetLoadFields for one field \u2014 See agent review for details.", "severity": "medium"}], "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": "performance-031", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/ReportBuilder.Codeunit.al\n+++ src/ReportBuilder.Codeunit.al\n+codeunit 50401 \"Report Builder\"\n+{\n+ Access = Public;\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.\", Description, Quantity, \"Line Amount\");\n+ Result := 'Item No.,Description,Quantity,Amount';\n+ if SalesLine.FindSet() then\n+ repeat\n+ Result += Format(SalesLine.\"No.\") + ',' +\n+ SalesLine.Description + ',' +\n+ Format(SalesLine.Quantity) + ',' +\n+ Format(SalesLine.\"Line Amount\");\n+ until SalesLine.Next() = 0;\n+ exit(Result);\n+ end;\n+\n+ procedure BuildHtmlNotification(VendorNo: Code[20]): Text\n+ var\n+ VendorLedgerEntry: Record \"Vendor Ledger Entry\";\n+ HtmlBody: Text;\n+ begin\n+ HtmlBody := '';\n+ HtmlBody += '';\n+ VendorLedgerEntry.SetRange(\"Vendor No.\", VendorNo);\n+ VendorLedgerEntry.SetRange(Open, true);\n+ VendorLedgerEntry.SetLoadFields(Description);\n+ if VendorLedgerEntry.FindSet() then\n+ repeat\n+ VendorLedgerEntry.CalcFields(\"Remaining Amount\");\n+ HtmlBody += '' +\n+ '' +\n+ '';\n+ until VendorLedgerEntry.Next() = 0;\n+ HtmlBody += '
Entry No.DescriptionAmount
' + Format(VendorLedgerEntry.\"Entry No.\") + '' + VendorLedgerEntry.Description + '' + Format(VendorLedgerEntry.\"Remaining Amount\") + '
';\n+ exit(HtmlBody);\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('Location: ' + LocationCode);\n+ Summary.AppendLine();\n+ Summary.Append('Item No. | Description | Quantity');\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+", "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\u00b2) performance for large record sets. \u2014 Use TextBuilder to accumulate the output string. TextBuilder.Append() is O(1) amortized and avoids repeated memory allocation.", "severity": "medium"}, {"file": "src/ReportBuilder.Codeunit.al", "line_start": 37, "line_end": 37, "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. \u2014 Use TextBuilder to construct the HTML body. Replace HtmlBody += with TextBuilder.Append() calls for efficient string building in loops.", "severity": "medium"}], "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\u00b2) memory allocations; TextBuilder should be used instead.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "performance-032", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "--- src/ItemMigrator.Codeunit.al\n+++ src/ItemMigrator.Codeunit.al\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+ // Performance issue: Modify(true) fires OnModify trigger per record\n+ // Item table can have 800k records \u2014 triggers run 800k times\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+}\n--- src/TestDataGenerator.Codeunit.al\n+++ src/TestDataGenerator.Codeunit.al\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+ // Performance issue: Insert(true) fires OnInsert for each record\n+ // Use Insert(false) when triggers aren't needed for test data\n+ ErrorMessageRegister.Insert(true);\n+ end;\n+ end;\n+}", "expected_comments": [{"file": "src/ItemMigrator.Codeunit.al", "line_start": 13, "line_end": 13, "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. \u2014 ", "severity": "low"}, {"file": "src/TestDataGenerator.Codeunit.al", "line_start": 15, "line_end": 15, "body": "Insert(true) in loop fires OnInsert triggers for each record. For test data or bulk inserts, use Insert(false) unless triggers are needed. \u2014 ", "severity": "low"}], "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": "privacy-001", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/PostalCodeLookupService.Codeunit.al\n+++ src/PostalCodeLookupService.Codeunit.al\n+codeunit 50113 PostalCodeLookupService\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+ RequestUri := StrSubstNo('https://postal-api.service.com/lookup?postal=%1&city=%2', PostalCode, City);\n+\n+ if HttpClient.Get(RequestUri, HttpResponse) then begin\n+ HttpResponse.Content.ReadAs(ResponseText);\n+ exit(ParseAddressResponse(ResponseText));\n+ end;\n+\n+ exit('');\n+ end;\n+\n+ local procedure ParseAddressResponse(JsonResponse: Text): Text[250]\n+ var\n+ JsonObject: JsonObject;\n+ AddressToken: JsonToken;\n+ begin\n+\n+ if JsonObject.ReadFrom(JsonResponse) then\n+ if JsonObject.Get('standardized_address', AddressToken) then\n+ exit(CopyStr(AddressToken.AsValue().AsText(), 1, 250));\n+\n+ exit('');\n+ end;\n+\n+ procedure ValidateBusinessAddress(var BusinessAddress: Record \"Business Address\")\n+ var\n+ StandardizedAddress: Text[250];\n+ begin\n+\n+ StandardizedAddress := LookupPostalAddress(BusinessAddress.\"Postal Code\", BusinessAddress.City);\n+\n+ if StandardizedAddress <> '' then begin\n+ BusinessAddress.\"Validated Address\" := StandardizedAddress;\n+ BusinessAddress.\"Validation Status\" := BusinessAddress.\"Validation Status\"::Validated;\n+ BusinessAddress.Modify();\n+ end;\n+ end;\n+}", "expected_comments": [], "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": "privacy-002", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/SystemConfigurationLog.Table.al\n+++ src/SystemConfigurationLog.Table.al\n+table 50111 \"System Configuration Log\"\n+{\n+ DataClassification = SystemMetadata; // System-level data for operational purposes\n+ TableType = Temporary;\n+\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer)\n+ {\n+ AutoIncrement = true;\n+ Caption = 'Entry No.';\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+}", "expected_comments": [], "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": "privacy-003", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/EAOutboxEmail.Table.al\n+++ src/EAOutboxEmail.Table.al\n+namespace Microsoft.ExpenseAgent.Email;\n+\n+using Microsoft.Foundation.Address;\n+using Microsoft.Foundation.Company;\n+using System.Email;\n+\n+table 57134 \"EA Outbox Email\"\n+{\n+ Caption = 'Expense Agent Outbox Email';\n+ DataCaptionFields = \"Entry No.\", Subject, \"To Line\";\n+\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer)\n+ {\n+ AutoIncrement = true;\n+ Caption = 'Entry No.';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(5; \"Message ID\"; Text[100])\n+ {\n+ Caption = 'Message ID';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(10; \"From Name\"; Text[100])\n+ {\n+ Caption = 'From Name';\n+ DataClassification = CustomerContent;\n+ }\n+ field(11; \"From Address\"; Text[250])\n+ {\n+ Caption = 'From Address';\n+ DataClassification = EndUserIdentifiableInformation;\n+ }\n+ field(15; Subject; Text[250])\n+ {\n+ Caption = 'Subject';\n+ DataClassification = CustomerContent;\n+ }\n+ field(20; \"To Line\"; Text[1000])\n+ {\n+ Caption = 'To Line';\n+ DataClassification = CustomerContent;\n+ }\n+ field(21; \"CC Line\"; Text[1000])\n+ {\n+ Caption = 'CC Line';\n+ DataClassification = CustomerContent;\n+ }\n+ field(22; \"BCC Line\"; Text[1000])\n+ {\n+ Caption = 'BCC Line';\n+ DataClassification = CustomerContent;\n+ }\n+ field(30; \"Body Text\"; BLOB)\n+ {\n+ Caption = 'Body Text';\n+ DataClassification = CustomerContent;\n+ }\n+ field(31; \"Body HTML\"; BLOB)\n+ {\n+ Caption = 'Body HTML';\n+ DataClassification = CustomerContent;\n+ }\n+ field(40; \"Attachment Count\"; Integer)\n+ {\n+ Caption = 'Attachment Count';\n+ DataClassification = SystemMetadata;\n+ Editable = false;\n+ }\n+ field(50; \"Created DateTime\"; DateTime)\n+ {\n+ Caption = 'Created Date Time';\n+ DataClassification = SystemMetadata;\n+ Editable = false;\n+ }\n+ field(51; \"Created By\"; Code[50])\n+ {\n+ Caption = 'Created By';\n+ DataClassification = EndUserPseudonymousIdentifiers;\n+ Editable = false;\n+ }\n+ field(60; \"Send Status\"; Enum \"EA Email Send Status\")\n+ {\n+ Caption = 'Send Status';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(61; \"Send DateTime\"; DateTime)\n+ {\n+ Caption = 'Send Date Time';\n+ DataClassification = SystemMetadata;\n+ Editable = false;\n+ }\n+ field(62; \"Send Error\"; Text[2048])\n+ {\n+ Caption = 'Send Error';\n+ DataClassification = CustomerContent;\n+ }\n+ field(70; \"Retry Count\"; Integer)\n+ {\n+ Caption = 'Retry Count';\n+ DataClassification = SystemMetadata;\n+ InitValue = 0;\n+ }\n+ field(71; \"Max Retry Count\"; Integer)\n+ {\n+ Caption = 'Max Retry Count';\n+ DataClassification = SystemMetadata;\n+ InitValue = 3;\n+ }\n+ field(80; \"Related Record Type\"; Enum \"EA Email Related Record Type\")\n+ {\n+ Caption = 'Related Record Type';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(81; \"Related Record ID\"; Code[50])\n+ {\n+ Caption = 'Related Record ID';\n+ DataClassification = CustomerContent;\n+ }\n+ field(90; \"Email Template Code\"; Code[20])\n+ {\n+ Caption = 'Email Template Code';\n+ TableRelation = \"EA Email Template\";\n+ DataClassification = SystemMetadata;\n+ }\n+ field(100; \"Priority\"; Option)\n+ {\n+ Caption = 'Priority';\n+ OptionMembers = Low,Normal,High,Urgent;\n+ OptionCaption = 'Low,Normal,High,Urgent';\n+ DataClassification = SystemMetadata;\n+ InitValue = Normal;\n+ }\n+ field(110; \"Language Code\"; Code[10])\n+ {\n+ Caption = 'Language Code';\n+ TableRelation = Language;\n+ DataClassification = SystemMetadata;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Entry No.\")\n+ {\n+ Clustered = true;\n+ }\n+ key(Key2; \"Send Status\", \"Created DateTime\")\n+ {\n+ }\n+ key(Key3; \"Related Record Type\", \"Related Record ID\")\n+ {\n+ }\n+ key(Key4; \"Send DateTime\")\n+ {\n+ }\n+ }\n+\n+ fieldgroups\n+ {\n+ fieldgroup(DropDown; \"Entry No.\", Subject, \"To Line\", \"Send Status\")\n+ {\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ if \"Created DateTime\" = 0DT then\n+ \"Created DateTime\" := CurrentDateTime;\n+\n+ if \"Created By\" = '' then\n+ \"Created By\" := CopyStr(UserId, 1, MaxStrLen(\"Created By\"));\n+\n+ if \"Message ID\" = '' then\n+ \"Message ID\" := CreateMessageId();\n+\n+ if \"Send Status\" = \"Send Status\"::\" \" then\n+ \"Send Status\" := \"Send Status\"::Pending;\n+\n+ ValidateEmailAddresses();\n+ end;\n+\n+ trigger OnModify()\n+ begin\n+ if (\"To Line\" <> xRec.\"To Line\") or (\"CC Line\" <> xRec.\"CC Line\") or (\"BCC Line\" <> xRec.\"BCC Line\") then\n+ ValidateEmailAddresses();\n+ end;\n+\n+ local procedure CreateMessageId(): Text[100]\n+ var\n+ CompanyInformation: Record \"Company Information\";\n+ MessageId: Text[100];\n+ begin\n+ CompanyInformation.Get();\n+ MessageId := Format(CreateGuid()) + '@';\n+\n+ if CompanyInformation.\"E-Mail\" <> '' then\n+ MessageId += GetDomainFromEmail(CompanyInformation.\"E-Mail\")\n+ else\n+ MessageId += 'expenseagent.local';\n+\n+ exit(CopyStr(MessageId, 1, MaxStrLen(MessageId)));\n+ end;\n+\n+ local procedure GetDomainFromEmail(EmailAddress: Text): Text\n+ var\n+ AtPosition: Integer;\n+ begin\n+ AtPosition := StrPos(EmailAddress, '@');\n+ if AtPosition > 0 then\n+ exit(CopyStr(EmailAddress, AtPosition + 1))\n+ else\n+ exit('unknown.domain');\n+ end;\n+\n+ local procedure ValidateEmailAddresses()\n+ var\n+ EmailValidation: Codeunit \"Email Address Validation\";\n+ begin\n+ if \"To Line\" <> '' then\n+ ValidateEmailList(\"To Line\", 'To');\n+\n+ if \"CC Line\" <> '' then\n+ ValidateEmailList(\"CC Line\", 'CC');\n+\n+ if \"BCC Line\" <> '' then\n+ ValidateEmailList(\"BCC Line\", 'BCC');\n+ end;\n+\n+ local procedure ValidateEmailList(EmailList: Text[1000]; FieldName: Text)\n+ var\n+ EmailArray: List of [Text];\n+ Email: Text;\n+ EmailValidation: Codeunit \"Email Address Validation\";\n+ Position: Integer;\n+ TempEmailList: Text;\n+ begin\n+ TempEmailList := EmailList;\n+\n+ repeat\n+ Position := StrPos(TempEmailList, ';');\n+ if Position = 0 then\n+ Position := StrPos(TempEmailList, ',');\n+\n+ if Position > 0 then begin\n+ Email := Trim(CopyStr(TempEmailList, 1, Position - 1));\n+ TempEmailList := Trim(CopyStr(TempEmailList, Position + 1));\n+ end else begin\n+ Email := Trim(TempEmailList);\n+ TempEmailList := '';\n+ end;\n+\n+ if Email <> '' then begin\n+ if not EmailValidation.IsValidEmailAddress(Email) then\n+ Error('Invalid email address in %1: %2', FieldName, Email);\n+ EmailArray.Add(Email);\n+ end;\n+ until TempEmailList = '';\n+\n+ if EmailArray.Count = 0 then\n+ Error('At least one valid email address is required in %1', FieldName);\n+ end;\n+\n+ procedure SetBodyText(BodyContent: Text)\n+ var\n+ OutStream: OutStream;\n+ begin\n+ \"Body Text\".CreateOutStream(OutStream, TEXTENCODING::UTF8);\n+ OutStream.WriteText(BodyContent);\n+ end;\n+\n+ procedure GetBodyText(): Text\n+ var\n+ InStream: InStream;\n+ BodyContent: Text;\n+ begin\n+ if not \"Body Text\".HasValue() then\n+ exit('');\n+\n+ \"Body Text\".CreateInStream(InStream, TEXTENCODING::UTF8);\n+ InStream.ReadText(BodyContent);\n+ exit(BodyContent);\n+ end;\n+\n+ procedure QueueForSending()\n+ begin\n+ TestField(Subject);\n+ TestField(\"To Line\");\n+\n+ if \"Send Status\" <> \"Send Status\"::Pending then begin\n+ \"Send Status\" := \"Send Status\"::Pending;\n+ \"Retry Count\" := 0;\n+ Clear(\"Send Error\");\n+ Modify(true);\n+ end;\n+ end;\n+}\n--- src/OutlookIntegrationHelper.Codeunit.al\n+++ src/OutlookIntegrationHelper.Codeunit.al\n+codeunit 50114 OutlookIntegrationHelper\n+{\n+ var\n+ // Privacy notice ID registered via Privacy Notice Registrations \u2014 text constants are the standard BC pattern for privacy notice IDs\n+ GraphNotificationTxt: Label 'MicrosoftGraphNotification', Locked = true;\n+\n+ procedure SendBusinessNotification(BusinessEmail: Text[250]; NotificationType: Text[50])\n+ var\n+ PrivacyNotice: Codeunit \"Privacy Notice\";\n+ PrivacyNoticeReg: Codeunit \"Privacy Notice Registrations\";\n+ HttpClient: HttpClient;\n+ HttpRequest: HttpRequestMessage;\n+ HttpResponse: HttpResponseMessage;\n+ JsonPayload: JsonObject;\n+ PayloadText: Text;\n+ begin\n+ if not (PrivacyNotice.GetPrivacyNoticeApprovalState(GraphNotificationTxt) = \"Privacy Notice Approval State\"::Agreed) then\n+ Error('Privacy notice for Microsoft Graph notifications has not been accepted.');\n+\n+ JsonPayload.Add('recipient', BusinessEmail);\n+ JsonPayload.Add('type', NotificationType);\n+ JsonPayload.Add('timestamp', CurrentDateTime);\n+ JsonPayload.WriteTo(PayloadText);\n+\n+ HttpRequest.SetRequestUri('https://graph.microsoft.com/v1.0/business/notifications');\n+ HttpRequest.Content.WriteFrom(PayloadText);\n+ HttpRequest.Content.GetHeaders().Add('Content-Type', 'application/json');\n+\n+ if HttpClient.Send(HttpRequest, HttpResponse) then\n+ Message('Business notification sent successfully')\n+ else\n+ Error('Failed to send business notification');\n+ end;\n+\n+ procedure ProcessBusinessContacts(var BusinessContact: Record \"Business Contact\")\n+ var\n+ ContactEmail: Text[250];\n+ ProcessedCount: Integer;\n+ begin\n+ if BusinessContact.FindSet() then\n+ repeat\n+ ContactEmail := BusinessContact.\"Email Address\";\n+\n+ if IsValidBusinessEmail(ContactEmail) then begin\n+ SendBusinessNotification(ContactEmail, 'BUSINESS_UPDATE');\n+ ProcessedCount += 1;\n+ end;\n+ until BusinessContact.Next() = 0;\n+\n+ Message('Processed %1 business email notifications', ProcessedCount);\n+ end;\n+\n+ local procedure IsValidBusinessEmail(Email: Text[250]): Boolean\n+ begin\n+ exit((Email <> '') and (StrPos(Email, '@') > 0));\n+ end;\n+}\n--- src/SOAFiltersImpl.Codeunit.al\n+++ src/SOAFiltersImpl.Codeunit.al\n+namespace Microsoft.SalesOrderAgent.Validation;\n+\n+using Microsoft.SalesOrderAgent.Integration;\n+using Microsoft.Foundation.NoSeries;\n+using System.Utilities;\n+\n+codeunit 57003 \"SOA Filters Impl\"\n+{\n+ trigger OnRun()\n+ begin\n+ end;\n+\n+ var\n+ EmailValidationLib: Codeunit \"Email Address\";\n+ InvalidEmailFormatMsg: Label 'Invalid email format in filter: %1', Comment = '%1 = email address';\n+ DuplicateEmailWarningMsg: Label 'Email %1 appears multiple times in the filter.', Comment = '%1 = email address';\n+ FilterAppliedMsg: Label 'Filter has been applied successfully.';\n+\n+ procedure ValidateCustomerEmailFilters(var SalesOrderFilters: Record \"SOA Sales Order Filters\")\n+ var\n+ TempEmailList: Record \"Name/Value Buffer\" temporary;\n+ EmailText: Text;\n+ EmailAddress: Text[250];\n+ Position: Integer;\n+ Counter: Integer;\n+ begin\n+ if SalesOrderFilters.\"Customer Email Filter\" = '' then\n+ exit;\n+\n+ EmailText := SalesOrderFilters.\"Customer Email Filter\";\n+\n+ repeat\n+ Position := StrPos(EmailText, ',');\n+ if Position > 0 then begin\n+ EmailAddress := CopyStr(Trim(CopyStr(EmailText, 1, Position - 1)), 1, 250);\n+ EmailText := Trim(CopyStr(EmailText, Position + 1));\n+ end else begin\n+ EmailAddress := CopyStr(Trim(EmailText), 1, 250);\n+ EmailText := '';\n+ end;\n+\n+ if EmailAddress <> '' then begin\n+ Counter += 1;\n+\n+ if not EmailValidationLib.IsValidEmailAddress(EmailAddress) then\n+ Error(InvalidEmailFormatMsg, EmailAddress);\n+\n+ TempEmailList.SetRange(Name, EmailAddress);\n+ if TempEmailList.FindFirst() then\n+ Message(DuplicateEmailWarningMsg, EmailAddress);\n+\n+ TempEmailList.Init();\n+ TempEmailList.Name := EmailAddress;\n+ TempEmailList.Value := Format(Counter);\n+ TempEmailList.Insert();\n+ end;\n+ until EmailText = '';\n+\n+ Message(FilterAppliedMsg);\n+ end;\n+\n+ procedure ApplyEmailNotificationFilter(CustomerEmail: Text[250]; var NotificationSettings: Record \"SOA Notification Setup\")\n+ var\n+ EmailFound: Boolean;\n+ NotificationMsg: Text;\n+ begin\n+ if CustomerEmail = '' then\n+ exit;\n+\n+ NotificationSettings.SetRange(\"Notification Type\", NotificationSettings.\"Notification Type\"::\"Email Alert\");\n+ NotificationSettings.SetRange(Enabled, true);\n+\n+ if NotificationSettings.FindSet() then begin\n+ repeat\n+ if StrPos(NotificationSettings.\"Email Filter\", CustomerEmail) > 0 then begin\n+ EmailFound := true;\n+ NotificationMsg := StrSubstNo('Customer with email %1 matches notification filter.', CustomerEmail);\n+\n+ SendEmailFilterNotification(NotificationMsg, CustomerEmail);\n+ end;\n+ until NotificationSettings.Next() = 0;\n+ end;\n+\n+ if not EmailFound then begin\n+ NotificationMsg := StrSubstNo('No matching notification rule found for email: %1', CustomerEmail);\n+ SendEmailFilterNotification(NotificationMsg, CustomerEmail);\n+ end;\n+ end;\n+\n+ local procedure SendEmailFilterNotification(NotificationText: Text; CustomerEmail: Text[250])\n+ var\n+ MyNotifications: Record \"My Notifications\";\n+ NotificationMgt: Codeunit \"Notification Management\";\n+ Notification: Notification;\n+ begin\n+ if not MyNotifications.IsEnabledForRecord('SOA_EMAIL_FILTER', Database::\"SOA Sales Order Filters\") then\n+ exit;\n+\n+ Notification.Id := CreateGuid();\n+ Notification.Message := NotificationText;\n+ Notification.Scope := NotificationScope::LocalScope;\n+\n+ Notification.SetData('EMAIL', CustomerEmail);\n+ Notification.SetData('FILTER_TYPE', 'EMAIL_NOTIFICATION');\n+\n+ Notification.Send();\n+\n+ LogEmailNotification(CustomerEmail, NotificationText);\n+ end;\n+\n+ local procedure LogEmailNotification(EmailAddress: Text[250]; NotificationText: Text)\n+ var\n+ SOAActivityLog: Record \"SOA Activity Log\";\n+ begin\n+ SOAActivityLog.Init();\n+ SOAActivityLog.\"Entry No.\" := GetNextEntryNo();\n+ SOAActivityLog.\"Activity Type\" := SOAActivityLog.\"Activity Type\"::Notification;\n+ SOAActivityLog.\"Activity DateTime\" := CurrentDateTime;\n+ SOAActivityLog.\"User ID\" := UserId;\n+ SOAActivityLog.Description := CopyStr(NotificationText, 1, MaxStrLen(SOAActivityLog.Description));\n+ SOAActivityLog.\"Related Email\" := EmailAddress;\n+ SOAActivityLog.\"Status\" := SOAActivityLog.Status::Completed;\n+ SOAActivityLog.Insert(true);\n+ end;\n+\n+ local procedure GetNextEntryNo(): Integer\n+ var\n+ SOAActivityLog: Record \"SOA Activity Log\";\n+ begin\n+ SOAActivityLog.LockTable();\n+ if SOAActivityLog.FindLast() then\n+ exit(SOAActivityLog.\"Entry No.\" + 1)\n+ else\n+ exit(1);\n+ end;\n+\n+ procedure ProcessCustomerContactEmails(var CustomerRec: Record Customer)\n+ var\n+ Contact: Record Contact;\n+ ContactBusinessRelation: Record \"Contact Business Relation\";\n+ EmailProcessor: Codeunit \"SOA Email Processor\";\n+ begin\n+ ContactBusinessRelation.SetCurrentKey(\"Link to Table\", \"No.\");\n+ ContactBusinessRelation.SetRange(\"Link to Table\", ContactBusinessRelation.\"Link to Table\"::Customer);\n+ ContactBusinessRelation.SetRange(\"No.\", CustomerRec.\"No.\");\n+\n+ if ContactBusinessRelation.FindFirst() then begin\n+ Contact.SetRange(\"Company No.\", ContactBusinessRelation.\"Contact No.\");\n+ Contact.SetRange(Type, Contact.Type::Person);\n+ Contact.SetFilter(\"E-Mail\", '<>%1', '');\n+\n+ if Contact.FindSet() then\n+ repeat\n+ EmailProcessor.QueueContactEmailValidation(Contact.\"E-Mail\", Contact.\"No.\", CustomerRec.\"No.\");\n+ until Contact.Next() = 0;\n+ end;\n+ end;\n+}", "expected_comments": [], "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": "privacy-004", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/SystemErrorHandler.Codeunit.al\n+++ src/SystemErrorHandler.Codeunit.al\n+codeunit 50115 SystemErrorHandler\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('Error logged with reference: %1', SystemId);\n+ end;\n+\n+ procedure LogDocumentError(DocumentId: Guid; DocumentType: Text[50]; ErrorText: Text[500])\n+ begin\n+\n+ Session.LogMessage('DocError',\n+ StrSubstNo('Document Error - ID: %1, Type: %2, Error: %3', DocumentId, DocumentType, ErrorText),\n+ Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, '', '');\n+ end;\n+\n+ procedure ProcessSystemValidationError(RecordId: RecordId; ValidationField: Text[50])\n+ var\n+ ErrorMessage: Text[500];\n+ begin\n+\n+ ErrorMessage := StrSubstNo('Validation failed for record %1 in field %2', 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+ if ErrorLogEntry.FindLast() then\n+ exit(ErrorLogEntry.\"Entry No.\" + 1);\n+ exit(1);\n+ end;\n+}", "expected_comments": [], "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": "privacy-005", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/BusinessIntegrationEvents.Codeunit.al\n+++ src/BusinessIntegrationEvents.Codeunit.al\n+codeunit 50117 BusinessIntegrationEvents\n+{\n+ [IntegrationEvent(true, false)]\n+ procedure OnBeforeProcessBusinessEntity(var BusinessEntity: Record \"Business Entity\"; var IsHandled: Boolean)\n+ begin\n+ end;\n+\n+ [IntegrationEvent(true, false)]\n+ procedure OnAfterValidateBusinessRegistration(BusinessRegistration: Record \"Business Registration\"; ValidationResult: Boolean)\n+ begin\n+ end;\n+\n+ procedure ProcessBusinessEntityBatch(var BusinessEntityBuffer: Record \"Business Entity\" temporary)\n+ var\n+ IsHandled: Boolean;\n+ ProcessedCount: Integer;\n+ begin\n+ if BusinessEntityBuffer.FindSet() then\n+ repeat\n+ OnBeforeProcessBusinessEntity(BusinessEntityBuffer, IsHandled);\n+\n+ if not IsHandled then begin\n+ ProcessSingleBusinessEntity(BusinessEntityBuffer);\n+ ProcessedCount += 1;\n+ end;\n+\n+ OnAfterBusinessEntityProcessed(BusinessEntityBuffer.\"Entity No.\", ProcessedCount);\n+ until BusinessEntityBuffer.Next() = 0;\n+ end;\n+\n+ [IntegrationEvent(true, false)]\n+ 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+}", "expected_comments": [], "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": "privacy-006", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/BusinessSystemLogger.Codeunit.al\n+++ src/BusinessSystemLogger.Codeunit.al\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('Vendor %1 processing logged successfully', 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('Batch processing completed: %1 vendors processed', ProcessedCount);\n+ end;\n+}", "expected_comments": [], "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": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "privacy-007", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/GeneralPrivacyOperations.Codeunit.al\n+++ src/GeneralPrivacyOperations.Codeunit.al\n+codeunit 50119 GeneralPrivacyOperations\n+{\n+ procedure ProcessSystemBackup(BackupId: Guid; BackupType: Text[50])\n+ var\n+ SystemBackupLog: Record \"System Backup Log\";\n+ begin\n+ \n+ SystemBackupLog.Init();\n+ SystemBackupLog.\"Backup ID\" := BackupId;\n+ SystemBackupLog.\"Backup Type\" := BackupType;\n+ SystemBackupLog.\"Start Time\" := CurrentDateTime;\n+ SystemBackupLog.\"Status\" := SystemBackupLog.Status::\"In Progress\";\n+ SystemBackupLog.Insert();\n+ \n+ Message('System backup initiated: %1', BackupId);\n+ end;\n+\n+ procedure ArchiveOldTransactionData(CutoffDate: Date)\n+ var\n+ TransactionArchive: Record \"Transaction Archive\";\n+ ArchivedCount: Integer;\n+ begin\n+ \n+ TransactionArchive.SetFilter(\"Transaction Date\", '<%1', CutoffDate);\n+ ArchivedCount := TransactionArchive.Count;\n+ \n+ if ArchivedCount > 0 then begin\n+ ProcessArchivalBatch(TransactionArchive);\n+ Message('Archived %1 transaction records per retention policy', ArchivedCount);\n+ end;\n+ end;\n+\n+ local procedure ProcessArchivalBatch(var TransactionData: Record \"Transaction Archive\")\n+ begin\n+ if TransactionData.FindSet() then\n+ repeat\n+ TransactionData.\"Archival Status\" := TransactionData.\"Archival Status\"::Archived;\n+ TransactionData.\"Archival Date\" := Today;\n+ TransactionData.Modify();\n+ until TransactionData.Next() = 0;\n+ end;\n+\n+ procedure GenerateComplianceReport(ReportType: Text[50]): Text[500]\n+ begin\n+ \n+ exit(StrSubstNo('Compliance Report Type: %1 generated on %2', ReportType, Format(CurrentDateTime)));\n+ end;\n+}", "expected_comments": [], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "Other privacy false positives (8 false positives). Miscellaneous privacy findings that were rejected by reviewers.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "privacy-008", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/UserPermissionBuffer.Table.al\n+++ src/UserPermissionBuffer.Table.al\n+table 50118 \"User Permission Buffer\"\n+{\n+ DataClassification = SystemMetadata; // System security data, not personal information\n+ TableType = Temporary;\n+ \n+ fields\n+ {\n+ field(1; \"User Security ID\"; Guid)\n+ {\n+ Caption = 'User Security ID';\n+ }\n+ field(2; \"Permission Set ID\"; Code[30])\n+ {\n+ Caption = 'Permission Set ID';\n+ }\n+ field(3; \"Object Type\"; Option)\n+ {\n+ OptionMembers = ,Table,Report,Codeunit,XMLport,MenuSuite,Page,Query,System;\n+ Caption = 'Object Type';\n+ }\n+ field(4; \"Object ID\"; Integer)\n+ {\n+ Caption = 'Object ID';\n+ }\n+ field(5; \"Read Permission\"; Option)\n+ {\n+ OptionMembers = \" \",Yes,Indirect;\n+ Caption = 'Read Permission';\n+ }\n+ }\n+\n+}", "expected_comments": [], "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": "privacy-009", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/BusinessEntityRegistry.Table.al\n+++ src/BusinessEntityRegistry.Table.al\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+}", "expected_comments": [], "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": "privacy-010", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/TaxDataMigrationHelper.Codeunit.al\n+++ src/TaxDataMigrationHelper.Codeunit.al\n+codeunit 50116 TaxDataMigrationHelper\n+{\n+ procedure MigrateTaxInformation(var VendorRecord: Record Vendor; SourceTaxId: Text[50])\n+ begin\n+\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+\n+ exit(ValidationAssert.ValidateAreEqual(ExpectedTaxId, ActualTaxId, true, // ShouldRedact = true\n+ 'Tax ID validation during migration'));\n+ end;\n+\n+ local procedure FormatTaxId(RawTaxId: Text[50]): Text[50]\n+ begin\n+\n+ exit(DelChr(RawTaxId, '=', '-()., '));\n+ end;\n+\n+ procedure LogMigrationProgress(TotalRecords: Integer; ProcessedRecords: Integer)\n+ begin\n+\n+ Session.LogMessage('TaxMigration',\n+ StrSubstNo('Tax data migration progress: %1 of %2 records processed', ProcessedRecords, TotalRecords),\n+ Verbosity::Information, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, '', '');\n+ end;\n+}", "expected_comments": [], "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": "privacy-011", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/ExternalCRMSync.Codeunit.al\n+++ src/ExternalCRMSync.Codeunit.al\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+\n--- src/OutboxEmailDispatcher.Codeunit.al\n+++ src/OutboxEmailDispatcher.Codeunit.al\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+", "expected_comments": [{"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 \u2014 Add Privacy Notice consent verification before sending customer data externally", "severity": "medium"}, {"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 \u2014 Verify Privacy Notice consent for Exchange/Graph integration before sending emails", "severity": "medium"}], "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": "privacy-012", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/ContactSyncFolder.Table.al\n+++ src/ContactSyncFolder.Table.al\n+namespace Microsoft.CRM.Outlook;\n+\n+using Microsoft.Foundation.Text;\n+using System.IO;\n+\n+table 5368 \"Contact Sync Folder\"\n+{\n+ Caption = 'Contact Sync Folder';\n+ ReplicateData = false;\n+\n+ fields\n+ {\n+ field(1; \"User Security ID\"; Guid)\n+ {\n+ Caption = 'User Security ID';\n+ DataClassification = EndUserPseudonymousIdentifiers;\n+ Description = 'User ID for contact sync';\n+ NotBlank = true;\n+ }\n+ field(5; Container; Text[250])\n+ {\n+ Caption = 'Container';\n+ DataClassification = SystemMetadata;\n+ Description = 'Container name for contact folder';\n+ }\n+ field(28; \"Parent Id\"; Text[250])\n+ {\n+ Caption = 'Parent Id';\n+ DataClassification = CustomerContent;\n+ Description = 'Folder parent identifier from Microsoft Graph';\n+ }\n+ field(30; \"Display Name\"; Text[250])\n+ {\n+ Caption = 'Display Name';\n+ DataClassification = CustomerContent;\n+ Description = 'Display name of the contact folder';\n+ }\n+ field(35; \"Child Count\"; Integer)\n+ {\n+ Caption = 'Child Count';\n+ DataClassification = SystemMetadata;\n+ Description = 'Number of child folders';\n+ }\n+ field(40; \"Unread Item Count\"; Integer)\n+ {\n+ Caption = 'Unread Item Count';\n+ DataClassification = SystemMetadata;\n+ Description = 'Number of unread items in folder';\n+ }\n+ field(50; \"Folder Class\"; Text[100])\n+ {\n+ Caption = 'Folder Class';\n+ DataClassification = SystemMetadata;\n+ Description = 'Type of folder (e.g., IPF.Contact)';\n+ }\n+ field(60; \"Server Id\"; Text[250])\n+ {\n+ Caption = 'Server Id';\n+ DataClassification = SystemMetadata;\n+ Description = 'Server identifier for synchronization';\n+ }\n+ field(70; \"Contact Notes\"; Text[2048])\n+ {\n+ Caption = 'Contact Notes';\n+ DataClassification = SystemMetadata;\n+ Description = 'Free-text notes entered by the user about the contact';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"User Security ID\", Container)\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ TestField(\"User Security ID\");\n+ TestField(Container);\n+\n+ if \"Display Name\" = '' then\n+ \"Display Name\" := Container;\n+\n+ if \"Folder Class\" = '' then\n+ \"Folder Class\" := 'IPF.Contact';\n+ end;\n+\n+ trigger OnModify()\n+ begin\n+ TestField(\"User Security ID\");\n+ TestField(Container);\n+ end;\n+\n+ procedure GetFolderDisplayName(): Text[250]\n+ begin\n+ if \"Display Name\" <> '' then\n+ exit(\"Display Name\")\n+ else\n+ exit(Container);\n+ end;\n+\n+ procedure HasChildFolders(): Boolean\n+ begin\n+ exit(\"Child Count\" > 0);\n+ end;\n+\n+ procedure IsContactFolder(): Boolean\n+ begin\n+ exit(\"Folder Class\" = 'IPF.Contact');\n+ end;\n+}\n--- src/FinancialReport.Table.al\n+++ src/FinancialReport.Table.al\n+namespace Microsoft.Finance.FinancialReports;\n+\n+using Microsoft.Finance.Analysis;\n+using Microsoft.Finance.Dimension;\n+using System.Environment.Configuration;\n+\n+table 25 \"Financial Report\"\n+{\n+ Caption = 'Financial Report';\n+ DataCaptionFields = Name, Description;\n+\n+ fields\n+ {\n+ field(1; Name; Code[10])\n+ {\n+ Caption = 'Name';\n+ NotBlank = true;\n+ DataClassification = CustomerContent;\n+ }\n+ field(2; Description; Text[250])\n+ {\n+ Caption = 'Description';\n+ DataClassification = CustomerContent;\n+ }\n+ field(3; \"Financial Report Row Group\"; Code[10])\n+ {\n+ Caption = 'Financial Report Row Group';\n+ TableRelation = \"Financial Report Row Group\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(4; \"Financial Report Column Group\"; Code[10])\n+ {\n+ Caption = 'Financial Report Column Group';\n+ TableRelation = \"Financial Report Column Group\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(5; \"Default Column Layout\"; Code[10])\n+ {\n+ Caption = 'Default Column Layout';\n+ TableRelation = \"Financial Report Column Group\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(100; \"Analysis View Name\"; Code[10])\n+ {\n+ Caption = 'Analysis View Name';\n+ TableRelation = \"Analysis View\".Code where(\"Analysis Area\" = const(General));\n+ DataClassification = CustomerContent;\n+ }\n+ field(200; \"Business Unit Filter\"; Code[20])\n+ {\n+ Caption = 'Business Unit Filter';\n+ FieldClass = FlowFilter;\n+ TableRelation = \"Business Unit\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(250; \"G/L Budget Filter\"; Code[10])\n+ {\n+ Caption = 'G/L Budget Filter';\n+ FieldClass = FlowFilter;\n+ TableRelation = \"G/L Budget Name\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(300; \"Cost Budget Filter\"; Code[10])\n+ {\n+ Caption = 'Cost Budget Filter';\n+ FieldClass = FlowFilter;\n+ TableRelation = \"Cost Budget Name\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(309; CategoryCode; Code[20])\n+ {\n+ Caption = 'Category Code';\n+ TableRelation = \"Financial Report Category\";\n+ }\n+ field(350; \"Date Filter\"; Date)\n+ {\n+ Caption = 'Date Filter';\n+ FieldClass = FlowFilter;\n+ DataClassification = SystemMetadata;\n+ }\n+ field(400; \"Dimension 1 Filter\"; Code[20])\n+ {\n+ CaptionClass = '1,3,1';\n+ Caption = 'Dimension 1 Filter';\n+ FieldClass = FlowFilter;\n+ TableRelation = \"Dimension Value\".Code where(\"Global Dimension No.\" = const(1),\n+ Blocked = const(false));\n+ DataClassification = CustomerContent;\n+ }\n+ field(401; \"Dimension 2 Filter\"; Code[20])\n+ {\n+ CaptionClass = '1,3,2';\n+ Caption = 'Dimension 2 Filter';\n+ FieldClass = FlowFilter;\n+ TableRelation = \"Dimension Value\".Code where(\"Global Dimension No.\" = const(2),\n+ Blocked = const(false));\n+ DataClassification = CustomerContent;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; Name)\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ fieldgroups\n+ {\n+ fieldgroup(DropDown; Name, Description)\n+ {\n+ }\n+ fieldgroup(Brick; Name, Description)\n+ {\n+ }\n+ }\n+\n+ trigger OnDelete()\n+ var\n+ FinancialReportUserFilters: Record \"Financial Report User Filters\";\n+ begin\n+ FinancialReportUserFilters.SetRange(\"Financial Report Name\", Name);\n+ FinancialReportUserFilters.DeleteAll(true);\n+ end;\n+\n+ procedure DrillDown(ColumnLayoutName: Code[10]; FinancialReportRowNo: Code[10]; CellValue: Decimal)\n+ var\n+ FinancialReportRowGroup: Record \"Financial Report Row Group\";\n+ FinancialReportMgt: Codeunit \"Financial Reports Management\";\n+ begin\n+ if FinancialReportRowGroup.Get(\"Financial Report Row Group\") then\n+ FinancialReportMgt.DrillDown(Rec, ColumnLayoutName, FinancialReportRowNo, CellValue);\n+ end;\n+\n+ procedure LookupColumnLayout(): Code[10]\n+ var\n+ FinancialReportColumnGroup: Record \"Financial Report Column Group\";\n+ FinancialReportColumns: Page \"Financial Report Columns\";\n+ begin\n+ FinancialReportColumnGroup.SetFilter(Name, '<>%1', '');\n+ if PAGE.RunModal(PAGE::\"Financial Report Column Groups\", FinancialReportColumnGroup) = ACTION::LookupOK then begin\n+ FinancialReportColumns.SetTableView(FinancialReportColumnGroup);\n+ FinancialReportColumns.SetRecord(FinancialReportColumnGroup);\n+ exit(FinancialReportColumnGroup.Name);\n+ end;\n+ end;\n+\n+ procedure ValidateRowGroup()\n+ var\n+ FinancialReportRowGroup: Record \"Financial Report Row Group\";\n+ begin\n+ if \"Financial Report Row Group\" = '' then\n+ exit;\n+\n+ if not FinancialReportRowGroup.Get(\"Financial Report Row Group\") then\n+ FieldError(\"Financial Report Row Group\");\n+ end;\n+}\n--- src/O365Contact.Table.al\n+++ src/O365Contact.Table.al\n+namespace Microsoft.CRM.Outlook;\n+\n+using Microsoft.CRM.Contact;\n+using Microsoft.Foundation.Address;\n+\n+table 5370 \"O365 Contact\"\n+{\n+ Caption = 'Office 365 Contact';\n+ ExternalName = 'Contact';\n+ ExternalSchema = 'https://outlook.office.com/api/v1.0/Me/Contacts';\n+ TableType = MicrosoftGraph;\n+\n+ fields\n+ {\n+ field(1; Id; Text[250])\n+ {\n+ Caption = 'Id';\n+ ExternalName = 'Id';\n+ ExternalType = 'Edm.String';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(2; CreatedDateTime; DateTime)\n+ {\n+ Caption = 'CreatedDateTime';\n+ ExternalName = 'CreatedDateTime';\n+ ExternalType = 'Edm.DateTimeOffset';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(3; LastModifiedDateTime; DateTime)\n+ {\n+ Caption = 'LastModifiedDateTime';\n+ ExternalName = 'LastModifiedDateTime';\n+ ExternalType = 'Edm.DateTimeOffset';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(4; Categories; BLOB)\n+ {\n+ Caption = 'Categories';\n+ ExternalName = 'Categories';\n+ ExternalType = 'Collection(Edm.String)';\n+ SubType = Json;\n+ DataClassification = CustomerContent;\n+ }\n+ field(5; ChangeKey; Text[250])\n+ {\n+ Caption = 'ChangeKey';\n+ ExternalName = 'ChangeKey';\n+ ExternalType = 'Edm.String';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(10; ParentFolderId; Text[250])\n+ {\n+ Caption = 'ParentFolderId';\n+ ExternalName = 'ParentFolderId';\n+ ExternalType = 'Edm.String';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(20; Birthday; DateTime)\n+ {\n+ Caption = 'Birthday';\n+ ExternalName = 'Birthday';\n+ ExternalType = 'Edm.DateTimeOffset';\n+ DataClassification = CustomerContent;\n+ }\n+ field(21; FileAs; Text[250])\n+ {\n+ Caption = 'FileAs';\n+ ExternalName = 'FileAs';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(22; DisplayName; Text[250])\n+ {\n+ Caption = 'DisplayName';\n+ ExternalName = 'DisplayName';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(23; GivenName; Text[250])\n+ {\n+ Caption = 'GivenName';\n+ ExternalName = 'GivenName';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(24; Initials; Text[250])\n+ {\n+ Caption = 'Initials';\n+ ExternalName = 'Initials';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(25; MiddleName; Text[250])\n+ {\n+ Caption = 'MiddleName';\n+ ExternalName = 'MiddleName';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(26; NickName; Text[250])\n+ {\n+ Caption = 'NickName';\n+ ExternalName = 'NickName';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(27; Surname; Text[250])\n+ {\n+ Caption = 'Surname';\n+ ExternalName = 'Surname';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(30; Title; Text[250])\n+ {\n+ Caption = 'Title';\n+ ExternalName = 'Title';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(40; YomiGivenName; Text[250])\n+ {\n+ Caption = 'YomiGivenName';\n+ ExternalName = 'YomiGivenName';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(41; YomiSurname; Text[250])\n+ {\n+ Caption = 'YomiSurname';\n+ ExternalName = 'YomiSurname';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(42; YomiCompanyName; Text[250])\n+ {\n+ Caption = 'YomiCompanyName';\n+ ExternalName = 'YomiCompanyName';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(50; Generation; Text[250])\n+ {\n+ Caption = 'Generation';\n+ ExternalName = 'Generation';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(60; ImAddresses; BLOB)\n+ {\n+ Caption = 'ImAddresses';\n+ ExternalName = 'ImAddresses';\n+ ExternalType = 'Collection(Edm.String)';\n+ SubType = Json;\n+ DataClassification = CustomerContent;\n+ }\n+ field(70; JobTitle; Text[250])\n+ {\n+ Caption = 'JobTitle';\n+ ExternalName = 'JobTitle';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(71; CompanyName; Text[250])\n+ {\n+ Caption = 'CompanyName';\n+ ExternalName = 'CompanyName';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(72; Department; Text[250])\n+ {\n+ Caption = 'Department';\n+ ExternalName = 'Department';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(73; OfficeLocation; Text[250])\n+ {\n+ Caption = 'OfficeLocation';\n+ ExternalName = 'OfficeLocation';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(80; Profession; Text[250])\n+ {\n+ Caption = 'Profession';\n+ ExternalName = 'Profession';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(81; BusinessHomePage; Text[250])\n+ {\n+ Caption = 'BusinessHomePage';\n+ ExternalName = 'BusinessHomePage';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(82; AssistantName; Text[250])\n+ {\n+ Caption = 'AssistantName';\n+ ExternalName = 'AssistantName';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(83; Manager; Text[250])\n+ {\n+ Caption = 'Manager';\n+ ExternalName = 'Manager';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(90; HomePhones; BLOB)\n+ {\n+ Caption = 'HomePhones';\n+ ExternalName = 'HomePhones';\n+ ExternalType = 'Collection(Edm.String)';\n+ SubType = Json;\n+ DataClassification = CustomerContent;\n+ }\n+ field(91; MobilePhone; Text[250])\n+ {\n+ Caption = 'MobilePhone';\n+ ExternalName = 'MobilePhone1';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(100; HomeAddress; BLOB)\n+ {\n+ Caption = 'HomeAddress';\n+ ExternalName = 'HomeAddress';\n+ ExternalType = 'Microsoft.OutlookServices.PhysicalAddress';\n+ SubType = Json;\n+ DataClassification = CustomerContent;\n+ }\n+ field(104; County; Text[100])\n+ {\n+ Caption = 'County';\n+ ExternalName = 'County';\n+ ExternalType = 'Edm.String';\n+ }\n+ field(110; BusinessAddress; BLOB)\n+ {\n+ Caption = 'BusinessAddress';\n+ ExternalName = 'BusinessAddress';\n+ ExternalType = 'Microsoft.OutlookServices.PhysicalAddress';\n+ SubType = Json;\n+ DataClassification = CustomerContent;\n+ }\n+ field(120; OtherAddress; BLOB)\n+ {\n+ Caption = 'OtherAddress';\n+ ExternalName = 'OtherAddress';\n+ ExternalType = 'Microsoft.OutlookServices.PhysicalAddress';\n+ SubType = Json;\n+ DataClassification = CustomerContent;\n+ }\n+ field(130; EmailAddresses; BLOB)\n+ {\n+ Caption = 'EmailAddresses';\n+ ExternalName = 'EmailAddresses';\n+ ExternalType = 'Collection(Microsoft.OutlookServices.EmailAddress)';\n+ SubType = Json;\n+ DataClassification = CustomerContent;\n+ }\n+ field(140; SpouseName; Text[250])\n+ {\n+ Caption = 'SpouseName';\n+ ExternalName = 'SpouseName';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(141; PersonalNotes; Text[250])\n+ {\n+ Caption = 'PersonalNotes';\n+ ExternalName = 'PersonalNotes';\n+ ExternalType = 'Edm.String';\n+ DataClassification = CustomerContent;\n+ }\n+ field(150; Children; BLOB)\n+ {\n+ Caption = 'Children';\n+ ExternalName = 'Children';\n+ ExternalType = 'Collection(Edm.String)';\n+ SubType = Json;\n+ DataClassification = CustomerContent;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; Id)\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ fieldgroups\n+ {\n+ }\n+\n+ procedure GetHomeAddressCountryOrRegion(): Text\n+ var\n+ GraphCollectionMgtContact: Codeunit \"Graph Collection Mgt - Contact\";\n+ begin\n+ exit(GraphCollectionMgtContact.GetAddressCountryOrRegion(HomeAddress));\n+ end;\n+\n+ procedure GetBusinessAddressCountryOrRegion(): Text\n+ var\n+ GraphCollectionMgtContact: Codeunit \"Graph Collection Mgt - Contact\";\n+ begin\n+ exit(GraphCollectionMgtContact.GetAddressCountryOrRegion(BusinessAddress));\n+ end;\n+\n+ procedure HasEmailAddresses(): Boolean\n+ var\n+ GraphCollectionMgtContact: Codeunit \"Graph Collection Mgt - Contact\";\n+ begin\n+ exit(GraphCollectionMgtContact.HasEmailAddresses(EmailAddresses));\n+ end;\n+\n+ procedure GetPrimaryEmailAddress(): Text\n+ var\n+ GraphCollectionMgtContact: Codeunit \"Graph Collection Mgt - Contact\";\n+ begin\n+ exit(GraphCollectionMgtContact.GetPrimaryEmailAddress(EmailAddresses));\n+ end;\n+\n+ procedure GetDisplayNameValue(): Text\n+ begin\n+ if DisplayName <> '' then\n+ exit(DisplayName);\n+ if (GivenName <> '') and (Surname <> '') then\n+ exit(GivenName + ' ' + Surname);\n+ if GivenName <> '' then\n+ exit(GivenName);\n+ if Surname <> '' then\n+ exit(Surname);\n+ exit('');\n+ end;\n+}\n--- src/ServiceShipmentLine.Table.al\n+++ src/ServiceShipmentLine.Table.al\n+namespace Microsoft.Service.History;\n+\n+using Microsoft.Finance.Dimension;\n+using Microsoft.Inventory.Item;\n+using Microsoft.Inventory.Location;\n+using Microsoft.Service.Setup;\n+\n+table 5991 \"Service Shipment Line\"\n+{\n+ Caption = 'Service Shipment Line';\n+ DrillDownPageID = \"Posted Service Shipment Lines\";\n+ LookupPageID = \"Posted Service Shipment Lines\";\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; \"Sell-to Customer No.\"; Code[20])\n+ {\n+ Caption = 'Sell-to Customer No.';\n+ Editable = false;\n+ TableRelation = Customer;\n+ DataClassification = CustomerContent;\n+ }\n+ field(5; Type; Enum \"Service Line Type\")\n+ {\n+ Caption = 'Type';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(6; \"No.\"; Code[20])\n+ {\n+ CaptionClass = GetCaptionClass(FieldNo(\"No.\"));\n+ Caption = 'No.';\n+ TableRelation = if (Type = const(\" \")) \"Standard Text\"\n+ else\n+ if (Type = const(Item)) Item\n+ else\n+ if (Type = const(Resource)) Resource\n+ else\n+ if (Type = const(Cost)) \"Service Cost\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(7; \"Location Code\"; Code[10])\n+ {\n+ Caption = 'Location Code';\n+ TableRelation = Location;\n+ DataClassification = CustomerContent;\n+ }\n+ field(8; \"Posting Group\"; Code[20])\n+ {\n+ Caption = 'Posting Group';\n+ Editable = false;\n+ TableRelation = if (Type = const(Item)) \"Inventory Posting Group\"\n+ else\n+ if (Type = const(Resource)) \"Gen. Product Posting Group\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(10; \"Shipment Date\"; Date)\n+ {\n+ Caption = 'Shipment Date';\n+ DataClassification = CustomerContent;\n+ }\n+ field(11; Description; Text[100])\n+ {\n+ Caption = 'Description';\n+ DataClassification = CustomerContent;\n+ }\n+ field(12; \"Description 2\"; Text[50])\n+ {\n+ Caption = 'Description 2';\n+ DataClassification = CustomerContent;\n+ }\n+ field(13; \"Unit of Measure\"; Text[50])\n+ {\n+ Caption = 'Unit of Measure';\n+ DataClassification = CustomerContent;\n+ }\n+ field(15; Quantity; Decimal)\n+ {\n+ Caption = 'Quantity';\n+ DecimalPlaces = 0 : 5;\n+ DataClassification = CustomerContent;\n+ }\n+ field(417; \"External Document No.\"; Code[35])\n+ {\n+ Caption = 'External Document No.';\n+ }\n+ field(480; \"Dimension Set ID\"; Integer)\n+ {\n+ Caption = 'Dimension Set ID';\n+ Editable = false;\n+ TableRelation = \"Dimension Set Entry\";\n+ DataClassification = SystemMetadata;\n+\n+ trigger OnLookup()\n+ begin\n+ Rec.ShowDimensions();\n+ end;\n+ }\n+ field(1001; \"Job Task No.\"; Code[20])\n+ {\n+ Caption = 'Job Task No.';\n+ Editable = false;\n+ TableRelation = \"Job Task\".\"Job Task No.\" where(\"Job No.\" = field(\"Job No.\"));\n+ DataClassification = CustomerContent;\n+ }\n+ field(1002; \"Job Line Type\"; Enum \"Job Line Type\")\n+ {\n+ Caption = 'Job Line Type';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(1003; \"Job No.\"; Code[20])\n+ {\n+ Caption = 'Job No.';\n+ Editable = false;\n+ TableRelation = Job;\n+ DataClassification = CustomerContent;\n+ }\n+ field(5402; \"Variant Code\"; Code[10])\n+ {\n+ Caption = 'Variant Code';\n+ TableRelation = if (Type = const(Item)) \"Item Variant\".Code where(\"Item No.\" = field(\"No.\"));\n+ DataClassification = CustomerContent;\n+ }\n+ field(5403; \"Bin Code\"; Code[20])\n+ {\n+ Caption = 'Bin Code';\n+ TableRelation = Bin.Code where(\"Location Code\" = field(\"Location Code\"));\n+ DataClassification = CustomerContent;\n+ }\n+ field(5404; \"Qty. per Unit of Measure\"; Decimal)\n+ {\n+ Caption = 'Qty. per Unit of Measure';\n+ DecimalPlaces = 0 : 5;\n+ Editable = false;\n+ InitValue = 1;\n+ DataClassification = CustomerContent;\n+ }\n+ field(5407; \"Unit of Measure Code\"; Code[10])\n+ {\n+ Caption = 'Unit of Measure Code';\n+ TableRelation = if (Type = const(Item)) \"Item Unit of Measure\".Code where(\"Item No.\" = field(\"No.\"))\n+ else\n+ if (Type = const(Resource)) \"Resource Unit of Measure\".Code where(\"Resource No.\" = field(\"No.\"));\n+ DataClassification = CustomerContent;\n+ }\n+ field(5415; \"Quantity (Base)\"; Decimal)\n+ {\n+ Caption = 'Quantity (Base)';\n+ DecimalPlaces = 0 : 5;\n+ DataClassification = CustomerContent;\n+ }\n+ field(5700; \"Responsibility Center\"; Code[10])\n+ {\n+ Caption = 'Responsibility Center';\n+ TableRelation = \"Responsibility Center\";\n+ DataClassification = CustomerContent;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Document No.\", \"Line No.\")\n+ {\n+ Clustered = true;\n+ }\n+ key(Key2; \"Order No.\", \"Order Line No.\")\n+ {\n+ }\n+ key(Key3; \"Blanket Order No.\", \"Blanket Order Line No.\")\n+ {\n+ }\n+ key(Key4; \"Item Shpt. Entry No.\")\n+ {\n+ }\n+ key(Key5; \"Sell-to Customer No.\")\n+ {\n+ }\n+ key(Key6; \"Bill-to Customer No.\")\n+ {\n+ }\n+ key(Key7; Type, \"No.\")\n+ {\n+ }\n+ key(Key8; \"Location Code\")\n+ {\n+ }\n+ key(Key9; \"Order No.\", \"Shipment Date\")\n+ {\n+ }\n+ }\n+\n+ fieldgroups\n+ {\n+ }\n+\n+ procedure ShowDimensions()\n+ var\n+ DimMgt: Codeunit DimensionManagement;\n+ begin\n+ DimMgt.ShowDimensionSet(\"Dimension Set ID\", StrSubstNo('%1 %2 %3', TableCaption(), \"Document No.\", \"Line No.\"));\n+ end;\n+\n+ procedure GetCaptionClass(FieldNumber: Integer): Text[80]\n+ var\n+ ServiceShipmentHeader: Record \"Service Shipment Header\";\n+ begin\n+ if not ServiceShipmentHeader.Get(\"Document No.\") then\n+ exit('');\n+ if ServiceShipmentHeader.\"Language Code\" = '' then\n+ exit('');\n+ case FieldNumber of\n+ FieldNo(\"No.\"):\n+ exit('1,1,' + ServiceShipmentHeader.\"Language Code\");\n+ end;\n+ end;\n+\n+ local procedure GetItem()\n+ var\n+ Item: Record Item;\n+ begin\n+ TestField(\"No.\");\n+ if \"No.\" <> xRec.\"No.\" then\n+ Item.Get(\"No.\");\n+ end;\n+\n+ procedure UpdateDimensions()\n+ var\n+ DimMgt: Codeunit DimensionManagement;\n+ begin\n+ if \"Dimension Set ID\" = 0 then begin\n+ \"Dimension Set ID\" := DimMgt.GetDefaultDimID(\n+ DATABASE::\"Service Shipment Line\", 0, \"No.\", '',\n+ 0, 0, 0, 0, 0);\n+ end;\n+ end;\n+}", "expected_comments": [{"file": "src/ContactSyncFolder.Table.al", "line_start": 65, "line_end": 65, "body": "Field 'Contact Notes' has DataClassification = SystemMetadata but contains free-text user-entered notes about contacts, which is CustomerContent \u2014 Change DataClassification from SystemMetadata to CustomerContent. Free-text notes about contacts are user-entered personal data.", "severity": "medium"}, {"file": "src/FinancialReport.Table.al", "line_start": 70, "line_end": 70, "body": "Field 'CategoryCode' is missing DataClassification property \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/O365Contact.Table.al", "line_start": 236, "line_end": 236, "body": "Field 'County' is missing DataClassification property \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ServiceShipmentLine.Table.al", "line_start": 92, "line_end": 92, "body": "Field 'External Document No.' is missing DataClassification property \u2014 See agent comment for details.", "severity": "medium"}], "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": "privacy-013", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/ExpenseUser.Table.al\n+++ src/ExpenseUser.Table.al\n+namespace Microsoft.ExpenseAgent.MasterData;\n+\n+using Microsoft.HumanResources.Employee;\n+using Microsoft.Foundation.Address;\n+using System.Security.User;\n+\n+table 57110 \"Expense User\"\n+{\n+ Caption = 'Expense User';\n+ DataClassificationFields = \"eMail\";\n+\n+ fields\n+ {\n+ field(1; \"User Security ID\"; Guid)\n+ {\n+ Caption = 'User Security ID';\n+ DataClassification = EndUserPseudonymousIdentifiers;\n+ TableRelation = User.\"User Security ID\";\n+ NotBlank = true;\n+ }\n+ field(2; \"Employee No.\"; Code[20])\n+ {\n+ Caption = 'Employee No.';\n+ DataClassification = EndUserPseudonymousIdentifiers;\n+ TableRelation = Employee;\n+ NotBlank = true;\n+ }\n+ field(5; \"User Name\"; Code[50])\n+ {\n+ Caption = 'User Name';\n+ DataClassification = EndUserIdentifiableInformation;\n+ NotBlank = true;\n+ }\n+ field(6; \"Full Name\"; Text[100])\n+ {\n+ Caption = 'Full Name';\n+ DataClassification = EndUserIdentifiableInformation;\n+ }\n+ field(10; eMail; Text[250])\n+ {\n+ Caption = 'E-Mail';\n+ DataClassification = EndUserIdentifiableInformation;\n+ ExtendedDatatype = EMail;\n+\n+ trigger OnValidate()\n+ begin\n+ ValidateEmailAddress();\n+ end;\n+ }\n+ field(15; \"Manager User Security ID\"; Guid)\n+ {\n+ Caption = 'Manager User Security ID';\n+ DataClassification = EndUserPseudonymousIdentifiers;\n+ TableRelation = \"Expense User\".\"User Security ID\" where(\"Allow Expense Approval\" = const(true), \"Is Active\" = const(true));\n+ }\n+ field(16; \"Manager Employee No.\"; Code[20])\n+ {\n+ Caption = 'Manager Employee No.';\n+ DataClassification = EndUserPseudonymousIdentifiers;\n+ TableRelation = Employee;\n+ Editable = false;\n+ }\n+ field(20; \"Department Code\"; Code[20])\n+ {\n+ Caption = 'Department Code';\n+ DataClassification = CustomerContent;\n+ TableRelation = \"Dimension Value\".Code where(\"Global Dimension No.\" = const(1), Blocked = const(false));\n+ }\n+ field(25; \"Approval Limit\"; Decimal)\n+ {\n+ Caption = 'Approval Limit';\n+ DataClassification = CustomerContent;\n+ MinValue = 0;\n+ }\n+ field(30; \"Allow Expense Submission\"; Boolean)\n+ {\n+ Caption = 'Allow Expense Submission';\n+ DataClassification = SystemMetadata;\n+ InitValue = true;\n+ }\n+ field(31; \"Allow Expense Approval\"; Boolean)\n+ {\n+ Caption = 'Allow Expense Approval';\n+ DataClassification = SystemMetadata;\n+\n+ trigger OnValidate()\n+ begin\n+ if not \"Allow Expense Approval\" then\n+ TestField(\"Approval Limit\", 0);\n+ end;\n+ }\n+ field(35; \"Is Active\"; Boolean)\n+ {\n+ Caption = 'Is Active';\n+ DataClassification = SystemMetadata;\n+ InitValue = true;\n+ }\n+ field(40; \"Last Login DateTime\"; DateTime)\n+ {\n+ Caption = 'Last Login DateTime';\n+ DataClassification = SystemMetadata;\n+ Editable = false;\n+ }\n+ field(45; \"Created DateTime\"; DateTime)\n+ {\n+ Caption = 'Created DateTime';\n+ DataClassification = SystemMetadata;\n+ Editable = false;\n+ }\n+ field(46; \"Modified DateTime\"; DateTime)\n+ {\n+ Caption = 'Modified DateTime';\n+ DataClassification = SystemMetadata;\n+ Editable = false;\n+ }\n+ field(50; \"Default Currency Code\"; Code[10])\n+ {\n+ Caption = 'Default Currency Code';\n+ DataClassification = CustomerContent;\n+ TableRelation = Currency;\n+ }\n+ field(55; \"Time Zone ID\"; Text[100])\n+ {\n+ Caption = 'Time Zone ID';\n+ DataClassification = CustomerContent;\n+ }\n+ field(60; \"Language Code\"; Code[10])\n+ {\n+ Caption = 'Language Code';\n+ DataClassification = CustomerContent;\n+ TableRelation = Language;\n+ }\n+ field(65; \"Cost Center Code\"; Code[20])\n+ {\n+ Caption = 'Cost Center Code';\n+ DataClassification = CustomerContent;\n+ TableRelation = \"Dimension Value\".Code where(\"Global Dimension No.\" = const(2), Blocked = const(false));\n+ }\n+ field(70; \"Project Default Code\"; Code[20])\n+ {\n+ Caption = 'Project Default Code';\n+ DataClassification = CustomerContent;\n+ TableRelation = Job;\n+ }\n+ field(75; \"Global Dimension 1 Code\"; Code[20])\n+ {\n+ Caption = 'Global Dimension 1 Code';\n+ CaptionClass = '1,1,1';\n+ DataClassification = CustomerContent;\n+ TableRelation = \"Dimension Value\".Code where(\"Global Dimension No.\" = const(1), Blocked = const(false));\n+ }\n+ field(76; \"Global Dimension 2 Code\"; Code[20])\n+ {\n+ Caption = 'Global Dimension 2 Code';\n+ CaptionClass = '1,1,2';\n+ DataClassification = CustomerContent;\n+ TableRelation = \"Dimension Value\".Code where(\"Global Dimension No.\" = const(2), Blocked = const(false));\n+ }\n+ field(80; \"Notification Preferences\"; Text[1000])\n+ {\n+ Caption = 'Notification Preferences';\n+ DataClassification = CustomerContent;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"User Security ID\")\n+ {\n+ Clustered = true;\n+ }\n+ key(Key2; \"Employee No.\")\n+ {\n+ }\n+ key(Key3; eMail)\n+ {\n+ }\n+ key(Key4; \"Manager User Security ID\")\n+ {\n+ }\n+ }\n+\n+ fieldgroups\n+ {\n+ fieldgroup(DropDown; \"Employee No.\", \"Full Name\", eMail)\n+ {\n+ }\n+ fieldgroup(Brick; \"Employee No.\", \"Full Name\", \"Department Code\", \"Is Active\")\n+ {\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ if \"Created DateTime\" = 0DT then\n+ \"Created DateTime\" := CurrentDateTime;\n+ \"Modified DateTime\" := CurrentDateTime;\n+\n+ ValidateManagerHierarchy();\n+ SyncWithEmployee();\n+ end;\n+\n+ trigger OnModify()\n+ begin\n+ \"Modified DateTime\" := CurrentDateTime;\n+\n+ if \"Manager User Security ID\" <> xRec.\"Manager User Security ID\" then\n+ ValidateManagerHierarchy();\n+\n+ if \"Employee No.\" <> xRec.\"Employee No.\" then\n+ SyncWithEmployee();\n+ end;\n+\n+ trigger OnDelete()\n+ var\n+ ExpenseHeader: Record \"Expense Header\";\n+ SubordinateUser: Record \"Expense User\";\n+ begin\n+ ExpenseHeader.SetRange(\"Employee No.\", \"Employee No.\");\n+ ExpenseHeader.SetFilter(Status, '<>%1', ExpenseHeader.Status::Approved);\n+ if not ExpenseHeader.IsEmpty then\n+ Error('Cannot delete user %1. There are pending expense documents.', \"Full Name\");\n+\n+ SubordinateUser.SetRange(\"Manager User Security ID\", \"User Security ID\");\n+ SubordinateUser.ModifyAll(\"Manager User Security ID\", \"User Security ID\");\n+ end;\n+\n+ local procedure ValidateEmailAddress()\n+ var\n+ EmailValidation: Codeunit \"Email Address Validation\";\n+ DuplicateUser: Record \"Expense User\";\n+ begin\n+ if eMail = '' then\n+ exit;\n+\n+ if not EmailValidation.IsValidEmailAddress(eMail) then\n+ Error('Invalid email address format: %1', eMail);\n+\n+ DuplicateUser.SetCurrentKey(eMail);\n+ DuplicateUser.SetRange(eMail, eMail);\n+ DuplicateUser.SetFilter(\"User Security ID\", '<>%1', \"User Security ID\");\n+ DuplicateUser.SetRange(\"Is Active\", true);\n+\n+ if DuplicateUser.FindFirst() then\n+ Error('Email address %1 is already in use by %2', eMail, DuplicateUser.\"Full Name\");\n+ end;\n+\n+ local procedure ValidateManagerHierarchy()\n+ var\n+ ManagerUser: Record \"Expense User\";\n+ Employee: Record Employee;\n+ begin\n+ if IsNullGuid(\"Manager User Security ID\") then\n+ exit;\n+\n+ if \"Manager User Security ID\" = \"User Security ID\" then\n+ Error('A user cannot be their own manager.');\n+\n+ ManagerUser.SetRange(\"User Security ID\", \"Manager User Security ID\");\n+ ManagerUser.SetRange(\"Is Active\", true);\n+\n+ if not ManagerUser.FindFirst() then\n+ Error('Manager with User Security ID %1 does not exist or is inactive.', \"Manager User Security ID\");\n+\n+ if not ManagerUser.\"Allow Expense Approval\" then\n+ Error('Manager %1 is not authorized to approve expenses.', ManagerUser.\"Full Name\");\n+\n+ \"Manager Employee No.\" := ManagerUser.\"Employee No.\";\n+ end;\n+\n+ local procedure SyncWithEmployee()\n+ var\n+ Employee: Record Employee;\n+ User: Record User;\n+ begin\n+ if Employee.Get(\"Employee No.\") then begin\n+ if \"Full Name\" = '' then\n+ \"Full Name\" := Employee.FullName();\n+\n+ if eMail = '' then begin\n+ if Employee.\"Company E-Mail\" <> '' then\n+ eMail := Employee.\"Company E-Mail\"\n+ else if Employee.\"E-Mail\" <> '' then\n+ eMail := Employee.\"E-Mail\";\n+ end;\n+ end;\n+\n+ if User.Get(\"User Security ID\") then begin\n+ if \"User Name\" = '' then\n+ \"User Name\" := User.\"User Name\";\n+ end;\n+ end;\n+\n+ procedure GetManagerName(): Text[100]\n+ var\n+ ManagerUser: Record \"Expense User\";\n+ begin\n+ if IsNullGuid(\"Manager User Security ID\") then\n+ exit('');\n+\n+ ManagerUser.SetRange(\"User Security ID\", \"Manager User Security ID\");\n+ if ManagerUser.FindFirst() then\n+ exit(ManagerUser.\"Full Name\");\n+ end;\n+\n+ procedure HasApprovalRights(): Boolean\n+ begin\n+ exit(\"Allow Expense Approval\" and \"Is Active\");\n+ end;\n+\n+ procedure CanApproveAmount(Amount: Decimal): Boolean\n+ begin\n+ if not HasApprovalRights() then\n+ exit(false);\n+\n+ if \"Approval Limit\" = 0 then // Unlimited approval\n+ exit(true);\n+\n+ exit(Amount <= \"Approval Limit\");\n+ end;\n+\n+ procedure UpdateLastLogin()\n+ begin\n+ \"Last Login DateTime\" := CurrentDateTime;\n+ Modify(false);\n+ end;\n+\n+ var\n+ OnlyBCUserCanApproveErr: Label 'Only Business Central users can approve expenses. User %1 with email %2 is not a valid BC user.', Comment = '%1 = Full Name, %2 = Email';\n+\n+ procedure ValidateBCUserForApproval()\n+ var\n+ User: Record User;\n+ ErrorMsg: Text;\n+ begin\n+ if not \"Allow Expense Approval\" then\n+ exit;\n+\n+ User.SetRange(\"User Security ID\", \"User Security ID\");\n+ User.SetRange(State, User.State::Enabled);\n+\n+ if not User.FindFirst() then begin\n+ ErrorMsg := StrSubstNo(OnlyBCUserCanApproveErr, \"Full Name\", eMail);\n+ Error(ErrorMsg);\n+ end;\n+\n+ if User.\"License Type\" = User.\"License Type\"::\"External User\" then begin\n+ ErrorMsg := StrSubstNo(OnlyBCUserCanApproveErr, \"Full Name\", eMail);\n+ Error(ErrorMsg);\n+ end;\n+ end;\n+}\n--- src/JobQueueErrorHandler.Codeunit.al\n+++ src/JobQueueErrorHandler.Codeunit.al\n+namespace System.Threading;\n+\n+using Microsoft.Foundation.NoSeries;\n+using System.Environment.Configuration;\n+using System.Telemetry;\n+\n+codeunit 452 \"Job Queue Error Handler\"\n+{\n+ TableNo = \"Job Queue Entry\";\n+\n+ trigger OnRun()\n+ begin\n+ HandleJobQueueError(Rec);\n+ end;\n+\n+ var\n+ JobQueueEntryTxt: Label 'Job Queue Entry: %1', Comment = '%1 = Job Queue Entry ID';\n+ ErrorProcessingJobTxt: Label 'Error processing job queue entry %1. Error: %2', Comment = '%1 = Job Queue Entry ID, %2 = Error message';\n+ RetryAttemptTxt: Label 'Retry attempt %1 of %2 for Job Queue Entry %3', Comment = '%1 = Current attempt, %2 = Max attempts, %3 = Job Queue Entry ID';\n+\n+ local procedure HandleJobQueueError(var JobQueueEntry: Record \"Job Queue Entry\")\n+ var\n+ JobQueueManagement: Codeunit \"Job Queue Management\";\n+ Telemetry: Codeunit Telemetry;\n+ ErrorMsg: Text;\n+ CustomDimensions: Dictionary of [Text, Text];\n+ begin\n+ ErrorMsg := GetLastErrorText();\n+\n+ CustomDimensions.Add('JobQueueEntryId', Format(JobQueueEntry.ID));\n+ CustomDimensions.Add('ObjectType', Format(JobQueueEntry.\"Object Type to Run\"));\n+ CustomDimensions.Add('ObjectId', Format(JobQueueEntry.\"Object ID to Run\"));\n+ CustomDimensions.Add('CompanyName', CompanyName);\n+\n+ Telemetry.LogMessage('0000ABC', StrSubstNo(ErrorProcessingJobTxt, JobQueueEntry.ID, ErrorMsg),\n+ Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ JobQueueEntry.\"Error Message\" := CopyStr(ErrorMsg, 1, MaxStrLen(JobQueueEntry.\"Error Message\"));\n+ JobQueueEntry.Status := JobQueueEntry.Status::Error;\n+ JobQueueEntry.\"No. of Attempts to Run\" := JobQueueEntry.\"No. of Attempts to Run\" + 1;\n+ JobQueueEntry.\"Earliest Start Date/Time\" := CurrentDateTime + CalculateRetryDelay(JobQueueEntry);\n+\n+ if ShouldRetryJob(JobQueueEntry) then begin\n+ JobQueueEntry.Status := JobQueueEntry.Status::Ready;\n+\n+ Telemetry.LogMessage('0000ABD', StrSubstNo(RetryAttemptTxt,\n+ JobQueueEntry.\"No. of Attempts to Run\",\n+ JobQueueEntry.\"Maximum No. of Attempts to Run\",\n+ JobQueueEntry.ID),\n+ Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+ end else begin\n+ SendFailureNotification(JobQueueEntry, ErrorMsg);\n+ end;\n+\n+ JobQueueEntry.\"Last Ready State\" := CurrentDateTime;\n+ JobQueueEntry.Modify(true);\n+\n+ ClearLastError();\n+ end;\n+\n+ local procedure ShouldRetryJob(JobQueueEntry: Record \"Job Queue Entry\"): Boolean\n+ begin\n+ if JobQueueEntry.\"Maximum No. of Attempts to Run\" = 0 then\n+ exit(false);\n+\n+ exit(JobQueueEntry.\"No. of Attempts to Run\" < JobQueueEntry.\"Maximum No. of Attempts to Run\");\n+ end;\n+\n+ local procedure CalculateRetryDelay(JobQueueEntry: Record \"Job Queue Entry\"): Duration\n+ var\n+ BaseDelayMinutes: Integer;\n+ ExponentialFactor: Integer;\n+ begin\n+ BaseDelayMinutes := 5;\n+ ExponentialFactor := Power(2, JobQueueEntry.\"No. of Attempts to Run\" - 1);\n+\n+ if BaseDelayMinutes * ExponentialFactor > 240 then\n+ ExponentialFactor := 240 div BaseDelayMinutes;\n+\n+ exit(BaseDelayMinutes * ExponentialFactor * 60 * 1000); // Convert to milliseconds\n+ end;\n+\n+ local procedure SendFailureNotification(JobQueueEntry: Record \"Job Queue Entry\"; ErrorMessage: Text)\n+ var\n+ NotificationMgt: Codeunit \"Notification Management\";\n+ Company: Record Company;\n+ AdminUser: Record User;\n+ EmailMessage: Codeunit \"Email Message\";\n+ Email: Codeunit Email;\n+ Subject: Text;\n+ Body: Text;\n+ ToRecipients: List of [Text];\n+ begin\n+ AdminUser.SetRange(\"License Type\", AdminUser.\"License Type\"::\"Full User\");\n+ AdminUser.SetFilter(\"Windows Security ID\", '<>%1', '');\n+ AdminUser.SetRange(State, AdminUser.State::Enabled);\n+\n+ if not AdminUser.FindSet() then\n+ exit;\n+\n+ Subject := StrSubstNo('Job Queue Entry Failure - %1', JobQueueEntry.Description);\n+\n+ Body := 'A job queue entry has failed after exhausting all retry attempts.' + CRLF + CRLF;\n+ Body += 'Job Queue Entry ID: ' + Format(JobQueueEntry.ID) + CRLF;\n+ Body += 'Description: ' + JobQueueEntry.Description + CRLF;\n+ Body += 'Object Type: ' + Format(JobQueueEntry.\"Object Type to Run\") + CRLF;\n+ Body += 'Object ID: ' + Format(JobQueueEntry.\"Object ID to Run\") + CRLF;\n+ Body += 'Company: ' + CompanyName + CRLF;\n+ Body += 'Failed At: ' + Format(CurrentDateTime) + CRLF;\n+ Body += 'Attempts Made: ' + Format(JobQueueEntry.\"No. of Attempts to Run\") + CRLF + CRLF;\n+ Body += 'Error Message:' + CRLF;\n+ Body += ErrorMessage + CRLF + CRLF;\n+ Body += 'Please check the Job Queue Entries page for more details and take appropriate action.';\n+\n+ repeat\n+ if AdminUser.\"Contact Email\" <> '' then\n+ ToRecipients.Add(AdminUser.\"Contact Email\");\n+ until AdminUser.Next() = 0;\n+\n+ if ToRecipients.Count > 0 then begin\n+ EmailMessage.Create(ToRecipients, Subject, Body, true);\n+ Email.Send(EmailMessage, Enum::\"Email Scenario\"::\"Job Queue\");\n+ end;\n+ end;\n+\n+ procedure ResetJobQueueEntry(var JobQueueEntry: Record \"Job Queue Entry\")\n+ var\n+ Telemetry: Codeunit Telemetry;\n+ CustomDimensions: Dictionary of [Text, Text];\n+ begin\n+ JobQueueEntry.Status := JobQueueEntry.Status::Ready;\n+ JobQueueEntry.\"No. of Attempts to Run\" := 0;\n+ Clear(JobQueueEntry.\"Error Message\");\n+ JobQueueEntry.\"Earliest Start Date/Time\" := CurrentDateTime;\n+ JobQueueEntry.\"Last Ready State\" := CurrentDateTime;\n+ JobQueueEntry.Modify(true);\n+\n+ CustomDimensions.Add('JobQueueEntryId', Format(JobQueueEntry.ID));\n+ CustomDimensions.Add('ObjectType', Format(JobQueueEntry.\"Object Type to Run\"));\n+ CustomDimensions.Add('ObjectId', Format(JobQueueEntry.\"Object ID to Run\"));\n+\n+ Telemetry.LogMessage('0000ABE', StrSubstNo(JobQueueEntryTxt, JobQueueEntry.ID) + ' reset by user',\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+ end;\n+\n+ [EventSubscriber(ObjectType::Table, Database::\"Job Queue Entry\", 'OnAfterInsertEvent', '', false, false)]\n+ local procedure OnAfterInsertJobQueueEntry(var Rec: Record \"Job Queue Entry\")\n+ var\n+ Telemetry: Codeunit Telemetry;\n+ CustomDimensions: Dictionary of [Text, Text];\n+ begin\n+ if Rec.IsTemporary then\n+ exit;\n+\n+ CustomDimensions.Add('JobQueueEntryId', Format(Rec.ID));\n+ CustomDimensions.Add('ObjectType', Format(Rec.\"Object Type to Run\"));\n+ CustomDimensions.Add('ObjectId', Format(Rec.\"Object ID to Run\"));\n+ CustomDimensions.Add('RecurringJob', Format(Rec.\"Recurring Job\"));\n+\n+ Telemetry.LogMessage('0000ABF', StrSubstNo(JobQueueEntryTxt, Rec.ID) + ' created',\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+ end;\n+}\n--- src/ReleaseExpenseDocument.Codeunit.al\n+++ src/ReleaseExpenseDocument.Codeunit.al\n+namespace Microsoft.ExpenseAgent.Expense;\n+\n+using Microsoft.ExpenseAgent.MasterData;\n+using Microsoft.Foundation.Reporting;\n+using System.Globalization;\n+\n+codeunit 57089 \"Release Expense Document\"\n+{\n+ TableNo = \"Expense Header\";\n+\n+ trigger OnRun()\n+ begin\n+ ReleaseExpenseDocument(Rec);\n+ end;\n+\n+ var\n+ ExpenseDocumentReleasedMsg: Label 'Expense document %1 has been released successfully.', Comment = '%1 = Document No.';\n+ ExpenseDocumentReleaseFailedErr: Label 'Failed to release expense document %1. Please check the document and try again.', Comment = '%1 = Document No.';\n+ MissingApproverErr: Label 'Cannot release expense document %1. No approver is assigned.', Comment = '%1 = Document No.';\n+ InsufficientApprovalLimitErr: Label 'Cannot release expense document %1. The assigned approver does not have sufficient approval limits.', Comment = '%1 = Document No.';\n+ DocumentAlreadyReleasedErr: Label 'Expense document %1 is already released.', Comment = '%1 = Document No.';\n+ InvalidReceiptDataErr: Label 'Invalid receipt data in expense line %1. Receipt No.: %2, Expense Date: %3, Merchant: %4', Comment = '%1 = Line No., %2 = Receipt No., %3 = Expense Date, %4 = Merchant Name';\n+\n+ procedure ReleaseExpenseDocument(var ExpenseHeader: Record \"Expense Header\"): Boolean\n+ var\n+ ExpenseLine: Record \"Expense Line\";\n+ ExpenseUser: Record \"Expense User\";\n+ ApprovalMgt: Codeunit \"Expense Approval Management\";\n+ FeatureTelemetry: Codeunit \"Feature Telemetry\";\n+ ErrorMsg: Text;\n+ TotalAmount: Decimal;\n+ begin\n+ if ExpenseHeader.Status = ExpenseHeader.Status::Released then\n+ Error(DocumentAlreadyReleasedErr, ExpenseHeader.\"No.\");\n+\n+ ValidateApprover(ExpenseHeader);\n+\n+ ExpenseLine.SetRange(\"Document No.\", ExpenseHeader.\"No.\");\n+ if ExpenseLine.FindSet() then\n+ repeat\n+ ValidateExpenseLine(ExpenseLine);\n+ TotalAmount += ExpenseLine.Amount;\n+ until ExpenseLine.Next() = 0;\n+\n+ if not CheckApprovalLimits(ExpenseHeader, TotalAmount) then\n+ Error(InsufficientApprovalLimitErr, ExpenseHeader.\"No.\");\n+\n+ ExpenseHeader.Status := ExpenseHeader.Status::Released;\n+ ExpenseHeader.\"Released Date\" := Today;\n+ ExpenseHeader.\"Released Time\" := Time;\n+ ExpenseHeader.\"Released By\" := UserId;\n+\n+ if ExpenseHeader.Modify(true) then begin\n+ FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', GetTelemetryDimensions(ExpenseHeader));\n+\n+ ApprovalMgt.SendReleaseNotification(ExpenseHeader);\n+\n+ Message(ExpenseDocumentReleasedMsg, ExpenseHeader.\"No.\");\n+ exit(true);\n+ end else begin\n+ Error(ExpenseDocumentReleaseFailedErr, ExpenseHeader.\"No.\");\n+ end;\n+ end;\n+\n+ local procedure ValidateApprover(ExpenseHeader: Record \"Expense Header\")\n+ var\n+ ExpenseUser: Record \"Expense User\";\n+ begin\n+ if IsNullGuid(ExpenseHeader.\"Approver User ID\") then\n+ Error(MissingApproverErr, ExpenseHeader.\"No.\");\n+\n+ ExpenseUser.SetRange(\"User Security ID\", ExpenseHeader.\"Approver User ID\");\n+ ExpenseUser.SetRange(\"Is Active\", true);\n+ ExpenseUser.SetRange(\"Allow Expense Approval\", true);\n+\n+ if not ExpenseUser.FindFirst() then\n+ Error(MissingApproverErr, ExpenseHeader.\"No.\");\n+ end;\n+\n+ local procedure ValidateExpenseLine(ExpenseLine: Record \"Expense Line\")\n+ var\n+ ErrorMsg: Text;\n+ begin\n+ ExpenseLine.TestField(\"Expense Date\");\n+ ExpenseLine.TestField(Amount);\n+ ExpenseLine.TestField(\"Expense Type Code\");\n+\n+ if (ExpenseLine.\"Receipt No.\" <> '') or (ExpenseLine.\"Receipt Date\" <> 0D) or (ExpenseLine.\"Merchant Name\" <> '') then begin\n+ if (ExpenseLine.\"Receipt No.\" = '') or (ExpenseLine.\"Receipt Date\" = 0D) or (ExpenseLine.\"Merchant Name\" = '') then begin\n+ ErrorMsg := StrSubstNo(InvalidReceiptDataErr,\n+ ExpenseLine.\"Line No.\",\n+ ExpenseLine.\"Receipt No.\",\n+ ExpenseLine.\"Expense Date\",\n+ ExpenseLine.\"Merchant Name\");\n+ Error(ErrorMsg);\n+ end;\n+\n+ if ExpenseLine.\"Receipt Date\" <> ExpenseLine.\"Expense Date\" then begin\n+ ErrorMsg := 'Receipt date (%1) does not match expense date (%2) for line %3';\n+ Error(ErrorMsg, ExpenseLine.\"Receipt Date\", ExpenseLine.\"Expense Date\", ExpenseLine.\"Line No.\");\n+ end;\n+ end;\n+\n+ if ExpenseLine.Amount <= 0 then\n+ Error('Expense amount must be greater than zero for line %1', ExpenseLine.\"Line No.\");\n+\n+ if ExpenseLine.\"Currency Code\" <> '' then\n+ ValidateCurrency(ExpenseLine);\n+ end;\n+\n+ local procedure ValidateCurrency(ExpenseLine: Record \"Expense Line\")\n+ var\n+ Currency: Record Currency;\n+ CurrencyExchangeRate: Record \"Currency Exchange Rate\";\n+ begin\n+ if not Currency.Get(ExpenseLine.\"Currency Code\") then\n+ Error('Currency %1 does not exist for line %2', ExpenseLine.\"Currency Code\", ExpenseLine.\"Line No.\");\n+\n+ if ExpenseLine.\"Exchange Rate\" <= 0 then begin\n+ CurrencyExchangeRate.SetRange(\"Currency Code\", ExpenseLine.\"Currency Code\");\n+ CurrencyExchangeRate.SetFilter(\"Starting Date\", '<=%1', ExpenseLine.\"Expense Date\");\n+ if not CurrencyExchangeRate.FindLast() then\n+ Error('No exchange rate found for currency %1 on %2', ExpenseLine.\"Currency Code\", ExpenseLine.\"Expense Date\");\n+ end;\n+ end;\n+\n+ local procedure CheckApprovalLimits(ExpenseHeader: Record \"Expense Header\"; TotalAmount: Decimal): Boolean\n+ var\n+ ExpenseUser: Record \"Expense User\";\n+ GeneralLedgerSetup: Record \"General Ledger Setup\";\n+ begin\n+ ExpenseUser.SetRange(\"User Security ID\", ExpenseHeader.\"Approver User ID\");\n+ if not ExpenseUser.FindFirst() then\n+ exit(false);\n+\n+ GeneralLedgerSetup.Get();\n+\n+ if ExpenseHeader.\"Currency Code\" <> '' then\n+ TotalAmount := ConvertToLCY(TotalAmount, ExpenseHeader.\"Currency Code\", ExpenseHeader.\"Document Date\");\n+\n+ if ExpenseUser.\"Approval Limit\" = 0 then\n+ exit(true);\n+\n+ exit(TotalAmount <= ExpenseUser.\"Approval Limit\");\n+ end;\n+\n+ local procedure ConvertToLCY(Amount: Decimal; CurrencyCode: Code[10]; DocumentDate: Date): Decimal\n+ var\n+ CurrencyExchangeRate: Record \"Currency Exchange Rate\";\n+ begin\n+ exit(CurrencyExchangeRate.ExchangeAmtFCYToLCY(DocumentDate, CurrencyCode, Amount,\n+ CurrencyExchangeRate.ExchangeRate(DocumentDate, CurrencyCode)));\n+ end;\n+\n+ local procedure GetTelemetryDimensions(ExpenseHeader: Record \"Expense Header\"): Dictionary of [Text, Text]\n+ var\n+ Dimensions: Dictionary of [Text, Text];\n+ begin\n+ Dimensions.Add('DocumentNo', ExpenseHeader.\"No.\");\n+ Dimensions.Add('EmployeeNo', ExpenseHeader.\"Employee No.\");\n+ Dimensions.Add('CurrencyCode', ExpenseHeader.\"Currency Code\");\n+ Dimensions.Add('TotalAmount', Format(ExpenseHeader.\"Total Amount\"));\n+ Dimensions.Add('CompanyName', CompanyName);\n+ exit(Dimensions);\n+ end;\n+\n+ procedure ReopenExpenseDocument(var ExpenseHeader: Record \"Expense Header\")\n+ var\n+ FeatureTelemetry: Codeunit \"Feature Telemetry\";\n+ begin\n+ if ExpenseHeader.Status <> ExpenseHeader.Status::Released then\n+ Error('Document %1 is not released and cannot be reopened.', ExpenseHeader.\"No.\");\n+\n+ ExpenseHeader.Status := ExpenseHeader.Status::Open;\n+ Clear(ExpenseHeader.\"Released Date\");\n+ Clear(ExpenseHeader.\"Released Time\");\n+ Clear(ExpenseHeader.\"Released By\");\n+\n+ ExpenseHeader.Modify(true);\n+\n+ FeatureTelemetry.LogUsage('0000EA2', 'Expense Agent', 'Document Reopened', GetTelemetryDimensions(ExpenseHeader));\n+\n+ Message('Expense document %1 has been reopened.', ExpenseHeader.\"No.\");\n+ end;\n+\n+ [IntegrationEvent(false, false)]\n+ local procedure OnBeforeReleaseExpenseDocument(var ExpenseHeader: Record \"Expense Header\"; var IsHandled: Boolean)\n+ begin\n+ end;\n+\n+ [IntegrationEvent(false, false)]\n+ local procedure OnAfterReleaseExpenseDocument(var ExpenseHeader: Record \"Expense Header\")\n+ begin\n+ end;\n+}\n--- src/ReleaseExpReportDocument.Codeunit.al\n+++ src/ReleaseExpReportDocument.Codeunit.al\n+namespace Microsoft.ExpenseAgent.ExpenseReport;\n+\n+using Microsoft.ExpenseAgent.MasterData;\n+using Microsoft.Foundation.Reporting;\n+using System.Globalization;\n+\n+codeunit 57112 \"Release ExpReport Document\"\n+{\n+ TableNo = \"Expense Report Header\";\n+\n+ trigger OnRun()\n+ begin\n+ ReleaseExpenseReportDocument(Rec);\n+ end;\n+\n+ var\n+ ExpenseReportReleasedMsg: Label 'Expense report %1 has been released successfully.', Comment = '%1 = Report No.';\n+ ExpenseReportReleaseFailedErr: Label 'Failed to release expense report %1. Please check the report and try again.', Comment = '%1 = Report No.';\n+ MissingApproverErr: Label 'Cannot release expense report %1. No approver is assigned.', Comment = '%1 = Report No.';\n+ InsufficientApprovalLimitErr: Label 'Cannot release expense report %1. The assigned approver does not have sufficient approval limits.', Comment = '%1 = Report No.';\n+ DocumentAlreadyReleasedErr: Label 'Expense report %1 is already released.', Comment = '%1 = Report No.';\n+ InvalidReceiptDataErr: Label 'Invalid receipt data in expense line %1. Receipt No.: %2, Expense Date: %3, Merchant: %4', Comment = '%1 = Line No., %2 = Receipt No., %3 = Expense Date, %4 = Merchant Name';\n+ NoLinesErr: Label 'Cannot release expense report %1. The report has no lines.', Comment = '%1 = Report No.';\n+\n+ procedure ReleaseExpenseReportDocument(var ExpenseReportHeader: Record \"Expense Report Header\"): Boolean\n+ var\n+ ExpenseReportLine: Record \"Expense Report Line\";\n+ ExpenseUser: Record \"Expense User\";\n+ ApprovalMgt: Codeunit \"Expense Report Approval Management\";\n+ FeatureTelemetry: Codeunit \"Feature Telemetry\";\n+ ErrorMsg: Text;\n+ TotalAmount: Decimal;\n+ LineCount: Integer;\n+ begin\n+ if ExpenseReportHeader.Status = ExpenseReportHeader.Status::Released then\n+ Error(DocumentAlreadyReleasedErr, ExpenseReportHeader.\"No.\");\n+\n+ ExpenseReportLine.SetRange(\"Report No.\", ExpenseReportHeader.\"No.\");\n+ LineCount := ExpenseReportLine.Count();\n+ if LineCount = 0 then\n+ Error(NoLinesErr, ExpenseReportHeader.\"No.\");\n+\n+ ValidateApprover(ExpenseReportHeader);\n+\n+ if ExpenseReportLine.FindSet() then\n+ repeat\n+ ValidateExpenseReportLine(ExpenseReportLine);\n+ TotalAmount += ExpenseReportLine.Amount;\n+ until ExpenseReportLine.Next() = 0;\n+\n+ if not CheckApprovalLimits(ExpenseReportHeader, TotalAmount) then\n+ Error(InsufficientApprovalLimitErr, ExpenseReportHeader.\"No.\");\n+\n+ ExpenseReportHeader.Status := ExpenseReportHeader.Status::Released;\n+ ExpenseReportHeader.\"Released Date\" := Today;\n+ ExpenseReportHeader.\"Released Time\" := Time;\n+ ExpenseReportHeader.\"Released By\" := UserId;\n+ ExpenseReportHeader.\"Total Amount\" := TotalAmount;\n+\n+ if ExpenseReportHeader.Modify(true) then begin\n+ FeatureTelemetry.LogUsage('0000ER1', 'Expense Agent', 'Report Released', GetTelemetryDimensions(ExpenseReportHeader));\n+\n+ ApprovalMgt.SendReleaseNotification(ExpenseReportHeader);\n+\n+ ExpenseReportLine.SetRange(\"Report No.\", ExpenseReportHeader.\"No.\");\n+ ExpenseReportLine.ModifyAll(Status, ExpenseReportLine.Status::Released);\n+\n+ Message(ExpenseReportReleasedMsg, ExpenseReportHeader.\"No.\");\n+ exit(true);\n+ end else begin\n+ Error(ExpenseReportReleaseFailedErr, ExpenseReportHeader.\"No.\");\n+ end;\n+ end;\n+\n+ local procedure ValidateApprover(ExpenseReportHeader: Record \"Expense Report Header\")\n+ var\n+ ExpenseUser: Record \"Expense User\";\n+ begin\n+ if IsNullGuid(ExpenseReportHeader.\"Approver User ID\") then\n+ Error(MissingApproverErr, ExpenseReportHeader.\"No.\");\n+\n+ ExpenseUser.SetRange(\"User Security ID\", ExpenseReportHeader.\"Approver User ID\");\n+ ExpenseUser.SetRange(\"Is Active\", true);\n+ ExpenseUser.SetRange(\"Allow Expense Approval\", true);\n+\n+ if not ExpenseUser.FindFirst() then\n+ Error(MissingApproverErr, ExpenseReportHeader.\"No.\");\n+ end;\n+\n+ local procedure ValidateExpenseReportLine(ExpenseReportLine: Record \"Expense Report Line\")\n+ var\n+ ErrorMsg: Text;\n+ begin\n+ ExpenseReportLine.TestField(\"Expense Date\");\n+ ExpenseReportLine.TestField(Amount);\n+ ExpenseReportLine.TestField(\"Expense Type Code\");\n+\n+ if (ExpenseReportLine.\"Receipt No.\" <> '') or (ExpenseReportLine.\"Receipt Date\" <> 0D) or (ExpenseReportLine.\"Merchant Name\" <> '') then begin\n+ if (ExpenseReportLine.\"Receipt No.\" = '') or (ExpenseReportLine.\"Receipt Date\" = 0D) or (ExpenseReportLine.\"Merchant Name\" = '') then begin\n+ ErrorMsg := StrSubstNo(InvalidReceiptDataErr,\n+ ExpenseReportLine.\"Line No.\",\n+ ExpenseReportLine.\"Receipt No.\",\n+ ExpenseReportLine.\"Expense Date\",\n+ ExpenseReportLine.\"Merchant Name\");\n+ Error(ErrorMsg);\n+ end;\n+\n+ if ExpenseReportLine.\"Receipt Date\" <> ExpenseReportLine.\"Expense Date\" then begin\n+ ErrorMsg := 'Receipt date (%1) does not match expense date (%2) for line %3';\n+ Error(ErrorMsg, ExpenseReportLine.\"Receipt Date\", ExpenseReportLine.\"Expense Date\", ExpenseReportLine.\"Line No.\");\n+ end;\n+ end;\n+\n+ if ExpenseReportLine.Amount <= 0 then\n+ Error('Expense amount must be greater than zero for line %1', ExpenseReportLine.\"Line No.\");\n+\n+ if ExpenseReportLine.\"Expense Type Code\" = 'MILEAGE' then\n+ ValidateMileageData(ExpenseReportLine);\n+\n+ if ExpenseReportLine.\"Currency Code\" <> '' then\n+ ValidateCurrency(ExpenseReportLine);\n+ end;\n+\n+ local procedure ValidateMileageData(ExpenseReportLine: Record \"Expense Report Line\")\n+ var\n+ ErrorMsg: Text;\n+ begin\n+ if ExpenseReportLine.\"Distance (Miles)\" <= 0 then\n+ Error('Distance must be greater than zero for mileage expenses on line %1', ExpenseReportLine.\"Line No.\");\n+\n+ if (ExpenseReportLine.\"Start Location\" = '') or (ExpenseReportLine.\"End Location\" = '') then begin\n+ ErrorMsg := 'Start and End locations are required for mileage expense on line %1';\n+ Error(ErrorMsg, ExpenseReportLine.\"Line No.\");\n+ end;\n+ end;\n+\n+ local procedure ValidateCurrency(ExpenseReportLine: Record \"Expense Report Line\")\n+ var\n+ Currency: Record Currency;\n+ CurrencyExchangeRate: Record \"Currency Exchange Rate\";\n+ begin\n+ if not Currency.Get(ExpenseReportLine.\"Currency Code\") then\n+ Error('Currency %1 does not exist for line %2', ExpenseReportLine.\"Currency Code\", ExpenseReportLine.\"Line No.\");\n+\n+ if ExpenseReportLine.\"Exchange Rate\" <= 0 then begin\n+ CurrencyExchangeRate.SetRange(\"Currency Code\", ExpenseReportLine.\"Currency Code\");\n+ CurrencyExchangeRate.SetFilter(\"Starting Date\", '<=%1', ExpenseReportLine.\"Expense Date\");\n+ if not CurrencyExchangeRate.FindLast() then\n+ Error('No exchange rate found for currency %1 on %2', ExpenseReportLine.\"Currency Code\", ExpenseReportLine.\"Expense Date\");\n+ end;\n+ end;\n+\n+ local procedure CheckApprovalLimits(ExpenseReportHeader: Record \"Expense Report Header\"; TotalAmount: Decimal): Boolean\n+ var\n+ ExpenseUser: Record \"Expense User\";\n+ GeneralLedgerSetup: Record \"General Ledger Setup\";\n+ begin\n+ ExpenseUser.SetRange(\"User Security ID\", ExpenseReportHeader.\"Approver User ID\");\n+ if not ExpenseUser.FindFirst() then\n+ exit(false);\n+\n+ GeneralLedgerSetup.Get();\n+\n+ if ExpenseReportHeader.\"Currency Code\" <> '' then\n+ TotalAmount := ConvertToLCY(TotalAmount, ExpenseReportHeader.\"Currency Code\", ExpenseReportHeader.\"Report Date\");\n+\n+ if ExpenseUser.\"Approval Limit\" = 0 then\n+ exit(true);\n+\n+ exit(TotalAmount <= ExpenseUser.\"Approval Limit\");\n+ end;\n+\n+ local procedure ConvertToLCY(Amount: Decimal; CurrencyCode: Code[10]; ReportDate: Date): Decimal\n+ var\n+ CurrencyExchangeRate: Record \"Currency Exchange Rate\";\n+ begin\n+ exit(CurrencyExchangeRate.ExchangeAmtFCYToLCY(ReportDate, CurrencyCode, Amount,\n+ CurrencyExchangeRate.ExchangeRate(ReportDate, CurrencyCode)));\n+ end;\n+\n+ local procedure GetTelemetryDimensions(ExpenseReportHeader: Record \"Expense Report Header\"): Dictionary of [Text, Text]\n+ var\n+ Dimensions: Dictionary of [Text, Text];\n+ begin\n+ Dimensions.Add('ReportNo', ExpenseReportHeader.\"No.\");\n+ Dimensions.Add('EmployeeNo', ExpenseReportHeader.\"Employee No.\");\n+ Dimensions.Add('CurrencyCode', ExpenseReportHeader.\"Currency Code\");\n+ Dimensions.Add('TotalAmount', Format(ExpenseReportHeader.\"Total Amount\"));\n+ Dimensions.Add('CompanyName', CompanyName);\n+ exit(Dimensions);\n+ end;\n+\n+ procedure ReopenExpenseReportDocument(var ExpenseReportHeader: Record \"Expense Report Header\")\n+ var\n+ ExpenseReportLine: Record \"Expense Report Line\";\n+ FeatureTelemetry: Codeunit \"Feature Telemetry\";\n+ begin\n+ if ExpenseReportHeader.Status <> ExpenseReportHeader.Status::Released then\n+ Error('Report %1 is not released and cannot be reopened.', ExpenseReportHeader.\"No.\");\n+\n+ ExpenseReportHeader.Status := ExpenseReportHeader.Status::Open;\n+ Clear(ExpenseReportHeader.\"Released Date\");\n+ Clear(ExpenseReportHeader.\"Released Time\");\n+ Clear(ExpenseReportHeader.\"Released By\");\n+\n+ ExpenseReportHeader.Modify(true);\n+\n+ ExpenseReportLine.SetRange(\"Report No.\", ExpenseReportHeader.\"No.\");\n+ ExpenseReportLine.ModifyAll(Status, ExpenseReportLine.Status::Open);\n+\n+ FeatureTelemetry.LogUsage('0000ER2', 'Expense Agent', 'Report Reopened', GetTelemetryDimensions(ExpenseReportHeader));\n+\n+ Message('Expense report %1 has been reopened.', ExpenseReportHeader.\"No.\");\n+ end;\n+\n+ [IntegrationEvent(false, false)]\n+ local procedure OnBeforeReleaseExpenseReportDocument(var ExpenseReportHeader: Record \"Expense Report Header\"; var IsHandled: Boolean)\n+ begin\n+ end;\n+\n+ [IntegrationEvent(false, false)]\n+ local procedure OnAfterReleaseExpenseReportDocument(var ExpenseReportHeader: Record \"Expense Report Header\")\n+ begin\n+ end;\n+}", "expected_comments": [{"file": "src/ExpenseUser.Table.al", "line_start": 343, "line_end": 343, "body": "StrSubstNo pre-builds error message with PII (Full Name and eMail) into plain Text variable, then passes to Error() \u2014 PII leaks to telemetry \u2014 Use direct Error substitution or omit PII", "severity": "high"}, {"file": "src/ExpenseUser.Table.al", "line_start": 348, "line_end": 348, "body": "Second instance: StrSubstNo pre-builds error message with Full Name and eMail into ErrorMsg, then Error(ErrorMsg) leaks PII to telemetry \u2014 Use direct Error substitution or omit PII", "severity": "high"}, {"file": "src/JobQueueErrorHandler.Codeunit.al", "line_start": 36, "line_end": 36, "body": "GetLastErrorText passed through StrSubstNo to telemetry \u2014 may contain customer content \u2014 Log generic message or use CustomerContent classification", "severity": "medium"}, {"file": "src/ReleaseExpenseDocument.Codeunit.al", "line_start": 90, "line_end": 90, "body": "StrSubstNo pre-builds error message containing Merchant Name (CustomerContent) into Text variable, then passes to Error() \u2014 leaks to telemetry \u2014 Use direct substitution in Error()", "severity": "medium"}, {"file": "src/ReleaseExpenseDocument.Codeunit.al", "line_start": 160, "line_end": 160, "body": "Employee No. included as telemetry custom dimension \u2014 can identify individuals \u2014 Remove EmployeeNo from telemetry dimensions or use a hash", "severity": "medium"}, {"file": "src/ReleaseExpReportDocument.Codeunit.al", "line_start": 100, "line_end": 100, "body": "StrSubstNo pre-builds error message containing Merchant Name (CustomerContent) into Text variable, then passes to Error() \u2014 leaks to telemetry \u2014 Use direct substitution in Error()", "severity": "medium"}, {"file": "src/ReleaseExpReportDocument.Codeunit.al", "line_start": 186, "line_end": 186, "body": "Employee No. included as telemetry custom dimension \u2014 can identify individuals \u2014 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": "privacy-014", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/DeploymentConfig.Codeunit.al\n+++ src/DeploymentConfig.Codeunit.al\n+codeunit 57401 \"Deployment Config\"\n+{\n+ Access = Public;\n+\n+ var\n+ AdminEmailLbl: Label 'admin.bcteam@contoso.com';\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+\n--- src/PRReviewManager.Codeunit.al\n+++ src/PRReviewManager.Codeunit.al\n+codeunit 57400 \"PR Review Manager\"\n+{\n+ Access = Public;\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+\n+ if TargetBranch = 'release' then begin\n+ Reviewers.Add('sarah.connor@contoso.com');\n+ end;\n+\n+ exit(Reviewers);\n+ end;\n+\n+ procedure NotifyReviewers(PRNumber: Integer; TargetBranch: Text)\n+ var\n+ Reviewers: List of [Text];\n+ Reviewer: Text;\n+ EmailMgt: Codeunit \"Email Message\";\n+ begin\n+ Reviewers := GetDefaultReviewers(TargetBranch);\n+\n+ foreach Reviewer in Reviewers do\n+ SendReviewNotification(\n+ Reviewer,\n+ StrSubstNo('PR #%1 needs your review on %2', PRNumber, TargetBranch));\n+ end;\n+\n+ local procedure SendReviewNotification(EmailAddress: Text; Subject: Text)\n+ begin\n+ // Send notification\n+ end;\n+}\n+", "expected_comments": [{"file": "src/PRReviewManager.Codeunit.al", "line_start": 9, "line_end": 9, "body": "Hardcoded personal email addresses embedded in the reviewer list. Personnel contact data should be stored in configuration, not directly in source code. \u2014 Move reviewer contacts to a configuration table or group alias that can be updated without code changes", "severity": "medium"}, {"file": "src/DeploymentConfig.Codeunit.al", "line_start": 10, "line_end": 10, "body": "Hardcoded personal email addresses in source code for deployment notifications. These become part of version control history and are difficult to update when personnel changes. \u2014 Store notification recipients in a setup table or use distribution group addresses instead of individual emails", "severity": "medium"}], "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": "privacy-015", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/ContactSyncProcessor.Codeunit.al\n+++ src/ContactSyncProcessor.Codeunit.al\n+namespace Microsoft.CRM.Outlook;\n+\n+using Microsoft.CRM.Contact;\n+using Microsoft.Foundation.Address;\n+using System.Telemetry;\n+using System.Threading;\n+\n+codeunit 5368 \"Contact Sync Processor\"\n+{\n+ TableNo = \"Contact Sync User\";\n+\n+ trigger OnRun()\n+ begin\n+ ProcessContactSync(Rec);\n+ end;\n+\n+ var\n+ SyncStartedTxt: Label 'Contact synchronization started for user %1.', Comment = '%1 = User ID';\n+ SyncCompletedTxt: Label 'Contact synchronization completed for user %1. %2 contacts processed.', Comment = '%1 = User ID, %2 = Contact count';\n+ SyncFailedTxt: Label 'Contact synchronization failed for user %1. Error: %2', Comment = '%1 = User ID, %2 = Error message';\n+ BatchRequestStartedTxt: Label 'Batch request started for processing %1 contacts.', Comment = '%1 = Contact count';\n+ BatchRequestCompletedTxt: Label 'Batch request completed successfully. %1 contacts synchronized.', Comment = '%1 = Contact count';\n+ NoContactsToSyncMsg: Label 'No contacts to synchronize for user %1.', Comment = '%1 = User ID';\n+ SyncInProgressErr: Label 'Contact sync is already in progress for user %1.', Comment = '%1 = User ID';\n+\n+ procedure ProcessContactSync(var ContactSyncUser: Record \"Contact Sync User\")\n+ var\n+ O365Contact: Record \"O365 Contact\";\n+ Contact: Record Contact;\n+ GraphCollectionMgtContact: Codeunit \"Graph Collection Mgt - Contact\";\n+ Telemetry: Codeunit Telemetry;\n+ FeatureTelemetry: Codeunit \"Feature Telemetry\";\n+ CustomDimensions: Dictionary of [Text, Text];\n+ ContactsToSync: List of [Text];\n+ ProcessedCount: Integer;\n+ TotalCount: Integer;\n+ ErrorMsg: Text;\n+ SyncSuccess: Boolean;\n+ begin\n+ if ContactSyncUser.\"Sync Status\" = ContactSyncUser.\"Sync Status\"::Processing then\n+ Error(SyncInProgressErr, ContactSyncUser.\"User Security ID\");\n+\n+ ContactSyncUser.\"Sync Status\" := ContactSyncUser.\"Sync Status\"::Processing;\n+ ContactSyncUser.\"Last Sync Started\" := CurrentDateTime;\n+ ContactSyncUser.Modify(true);\n+\n+ CustomDimensions.Add('CompanyName', CompanyName);\n+\n+ Telemetry.LogMessage('0000CS01', StrSubstNo(SyncStartedTxt, ContactSyncUser.\"User Security ID\"),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ GetOffice365Contacts(ContactSyncUser, ContactsToSync);\n+ TotalCount := ContactsToSync.Count;\n+\n+ if TotalCount = 0 then begin\n+ ContactSyncUser.\"Sync Status\" := ContactSyncUser.\"Sync Status\"::Completed;\n+ ContactSyncUser.\"Last Sync Completed\" := CurrentDateTime;\n+ ContactSyncUser.Modify(true);\n+\n+ Message(NoContactsToSyncMsg, ContactSyncUser.\"User Security ID\");\n+ exit;\n+ end;\n+\n+ CustomDimensions.Add('ContactCount', Format(TotalCount));\n+ Telemetry.LogMessage('0000CS02', StrSubstNo(BatchRequestStartedTxt, TotalCount),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ SyncSuccess := TryProcessContactBatch(ContactSyncUser, ContactsToSync, ProcessedCount);\n+\n+ if SyncSuccess then begin\n+ ContactSyncUser.\"Sync Status\" := ContactSyncUser.\"Sync Status\"::Completed;\n+ ContactSyncUser.\"Last Sync Completed\" := CurrentDateTime;\n+ ContactSyncUser.\"Contacts Synchronized\" := ProcessedCount;\n+ ContactSyncUser.Modify(true);\n+\n+ CustomDimensions.Add('ProcessedCount', Format(ProcessedCount));\n+ CustomDimensions.Add('SyncDurationMs', Format(CurrentDateTime - ContactSyncUser.\"Last Sync Started\"));\n+\n+ Telemetry.LogMessage('0000CS03', StrSubstNo(BatchRequestCompletedTxt, ProcessedCount),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ Telemetry.LogMessage('0000CS04', StrSubstNo(BatchRequestCompletedTxt, ProcessedCount),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ FeatureTelemetry.LogUsage('0000CS05', 'Outlook Contact Sync', Enum::\"Feature Uptake Status\"::Used);\n+\n+ Message('Contact synchronization completed. %1 contacts processed.', ProcessedCount);\n+ end else begin\n+ ErrorMsg := GetLastErrorText();\n+\n+ ContactSyncUser.\"Sync Status\" := ContactSyncUser.\"Sync Status\"::Failed;\n+ ContactSyncUser.\"Last Error Message\" := CopyStr(ErrorMsg, 1, MaxStrLen(ContactSyncUser.\"Last Error Message\"));\n+ ContactSyncUser.Modify(true);\n+\n+ CustomDimensions.Add('ErrorMessage', ErrorMsg);\n+ Telemetry.LogMessage('0000CS06', 'Contact synchronization failed.',\n+ Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ Error('Contact synchronization failed.');\n+ end;\n+ end;\n+\n+ [TryFunction]\n+ local procedure TryProcessContactBatch(ContactSyncUser: Record \"Contact Sync User\"; ContactsToSync: List of [Text]; var ProcessedCount: Integer)\n+ begin\n+ ProcessedCount := ProcessContactBatch(ContactSyncUser, ContactsToSync);\n+ end;\n+\n+ local procedure GetOffice365Contacts(ContactSyncUser: Record \"Contact Sync User\"; var ContactsToSync: List of [Text])\n+ var\n+ O365Contact: Record \"O365 Contact\";\n+ ContactFilter: Text;\n+ ContactId: Text;\n+ begin\n+ // Set up filters for Office 365 contact query\n+ if ContactSyncUser.\"Last Sync Completed\" <> 0DT then begin\n+ // Use delta sync for incremental updates\n+ ContactFilter := StrSubstNo('$filter=lastModifiedDateTime gt %1', Format(ContactSyncUser.\"Last Sync Completed\", 0, 9));\n+ end else begin\n+ // Full sync for first time\n+ ContactFilter := '$filter=emailAddresses/any(e: e/address ne null)'; // Only contacts with email\n+ end;\n+\n+ // Query Office 365 Graph API (simulated)\n+ O365Contact.SetFilter(LastModifiedDateTime, '>%1', ContactSyncUser.\"Last Sync Completed\");\n+\n+ if O365Contact.FindSet() then\n+ repeat\n+ ContactsToSync.Add(O365Contact.Id);\n+ until O365Contact.Next() = 0;\n+ end;\n+\n+ local procedure ProcessContactBatch(ContactSyncUser: Record \"Contact Sync User\"; ContactsToSync: List of [Text]): Integer\n+ var\n+ O365Contact: Record \"O365 Contact\";\n+ Contact: Record Contact;\n+ ContactId: Text;\n+ ProcessedCount: Integer;\n+ begin\n+ foreach ContactId in ContactsToSync do begin\n+ if O365Contact.Get(ContactId) then begin\n+ if SyncIndividualContact(ContactSyncUser, O365Contact) then\n+ ProcessedCount += 1;\n+ end;\n+\n+ // Commit every 50 records to avoid transaction timeout\n+ if ProcessedCount mod 50 = 0 then\n+ Commit();\n+ end;\n+\n+ exit(ProcessedCount);\n+ end;\n+\n+ local procedure SyncIndividualContact(ContactSyncUser: Record \"Contact Sync User\"; O365Contact: Record \"O365 Contact\"): Boolean\n+ var\n+ Contact: Record Contact;\n+ ContactBusinessRelation: Record \"Contact Business Relation\";\n+ GraphCollectionMgtContact: Codeunit \"Graph Collection Mgt - Contact\";\n+ ExistingContactNo: Code[20];\n+ EmailAddress: Text[80];\n+ begin\n+ EmailAddress := CopyStr(O365Contact.GetPrimaryEmailAddress(), 1, 80);\n+\n+ if EmailAddress = '' then\n+ exit(false); // Skip contacts without email\n+\n+ // Try to find existing contact by email\n+ Contact.SetCurrentKey(\"E-Mail\");\n+ Contact.SetRange(\"E-Mail\", EmailAddress);\n+ if Contact.FindFirst() then begin\n+ ExistingContactNo := Contact.\"No.\";\n+ UpdateExistingContact(Contact, O365Contact);\n+ end else begin\n+ ExistingContactNo := CreateNewContact(ContactSyncUser, O365Contact, EmailAddress);\n+ end;\n+\n+ // Update sync tracking\n+ UpdateContactSyncTracking(ContactSyncUser, O365Contact.Id, ExistingContactNo);\n+\n+ exit(ExistingContactNo <> '');\n+ end;\n+\n+ local procedure UpdateExistingContact(var Contact: Record Contact; O365Contact: Record \"O365 Contact\")\n+ begin\n+ Contact.Name := CopyStr(O365Contact.GetDisplayNameValue(), 1, MaxStrLen(Contact.Name));\n+ Contact.\"First Name\" := CopyStr(O365Contact.GivenName, 1, MaxStrLen(Contact.\"First Name\"));\n+ Contact.Surname := CopyStr(O365Contact.Surname, 1, MaxStrLen(Contact.Surname));\n+ Contact.\"Job Title\" := CopyStr(O365Contact.JobTitle, 1, MaxStrLen(Contact.\"Job Title\"));\n+ Contact.\"Company Name\" := CopyStr(O365Contact.CompanyName, 1, MaxStrLen(Contact.\"Company Name\"));\n+ Contact.\"Mobile Phone No.\" := CopyStr(O365Contact.MobilePhone, 1, MaxStrLen(Contact.\"Mobile Phone No.\"));\n+\n+ // Update address information\n+ UpdateContactAddress(Contact, O365Contact);\n+\n+ Contact.Modify(true);\n+ end;\n+\n+ local procedure CreateNewContact(ContactSyncUser: Record \"Contact Sync User\"; O365Contact: Record \"O365 Contact\"; EmailAddress: Text[80]): Code[20]\n+ var\n+ Contact: Record Contact;\n+ NoSeriesManagement: Codeunit NoSeriesManagement;\n+ begin\n+ Contact.Init();\n+ Contact.\"No.\" := NoSeriesManagement.GetNextNo('CONT', Today, true);\n+ Contact.Type := Contact.Type::Person;\n+ Contact.Name := CopyStr(O365Contact.GetDisplayNameValue(), 1, MaxStrLen(Contact.Name));\n+ Contact.\"First Name\" := CopyStr(O365Contact.GivenName, 1, MaxStrLen(Contact.\"First Name\"));\n+ Contact.Surname := CopyStr(O365Contact.Surname, 1, MaxStrLen(Contact.Surname));\n+ Contact.\"E-Mail\" := EmailAddress;\n+ Contact.\"Job Title\" := CopyStr(O365Contact.JobTitle, 1, MaxStrLen(Contact.\"Job Title\"));\n+ Contact.\"Company Name\" := CopyStr(O365Contact.CompanyName, 1, MaxStrLen(Contact.\"Company Name\"));\n+ Contact.\"Mobile Phone No.\" := CopyStr(O365Contact.MobilePhone, 1, MaxStrLen(Contact.\"Mobile Phone No.\"));\n+\n+ // Set sync-related fields\n+ Contact.\"Privacy Blocked\" := false;\n+ Contact.\"External ID\" := CopyStr(O365Contact.Id, 1, MaxStrLen(Contact.\"External ID\"));\n+\n+ UpdateContactAddress(Contact, O365Contact);\n+\n+ Contact.Insert(true);\n+ exit(Contact.\"No.\");\n+ end;\n+\n+ local procedure UpdateContactAddress(var Contact: Record Contact; O365Contact: Record \"O365 Contact\")\n+ var\n+ GraphCollectionMgtContact: Codeunit \"Graph Collection Mgt - Contact\";\n+ BusinessAddress: Text;\n+ HomeAddress: Text;\n+ begin\n+ // Extract business address from Office 365 contact\n+ BusinessAddress := GraphCollectionMgtContact.GetBusinessAddressString(O365Contact.BusinessAddress);\n+ if BusinessAddress <> '' then begin\n+ // Parse and update business address fields\n+ // This is simplified - actual implementation would parse the JSON\n+ Contact.Address := CopyStr(BusinessAddress, 1, MaxStrLen(Contact.Address));\n+ Contact.City := CopyStr(GraphCollectionMgtContact.GetBusinessAddressCity(O365Contact.BusinessAddress), 1, MaxStrLen(Contact.City));\n+ Contact.\"Post Code\" := CopyStr(GraphCollectionMgtContact.GetBusinessAddressPostalCode(O365Contact.BusinessAddress), 1, MaxStrLen(Contact.\"Post Code\"));\n+ Contact.\"Country/Region Code\" := CopyStr(O365Contact.GetBusinessAddressCountryOrRegion(), 1, MaxStrLen(Contact.\"Country/Region Code\"));\n+ end;\n+ end;\n+\n+ local procedure UpdateContactSyncTracking(ContactSyncUser: Record \"Contact Sync User\"; O365ContactId: Text[250]; BCContactNo: Code[20])\n+ var\n+ ContactSyncMapping: Record \"Contact Sync Mapping\";\n+ begin\n+ ContactSyncMapping.SetRange(\"User Security ID\", ContactSyncUser.\"User Security ID\");\n+ ContactSyncMapping.SetRange(\"O365 Contact ID\", O365ContactId);\n+\n+ if ContactSyncMapping.FindFirst() then begin\n+ ContactSyncMapping.\"BC Contact No.\" := BCContactNo;\n+ ContactSyncMapping.\"Last Synchronized\" := CurrentDateTime;\n+ ContactSyncMapping.Modify(true);\n+ end else begin\n+ ContactSyncMapping.Init();\n+ ContactSyncMapping.\"User Security ID\" := ContactSyncUser.\"User Security ID\";\n+ ContactSyncMapping.\"O365 Contact ID\" := O365ContactId;\n+ ContactSyncMapping.\"BC Contact No.\" := BCContactNo;\n+ ContactSyncMapping.\"Last Synchronized\" := CurrentDateTime;\n+ ContactSyncMapping.Insert(true);\n+ end;\n+ end;\n+\n+ procedure ScheduleAutomaticSync()\n+ var\n+ ContactSyncUser: Record \"Contact Sync User\";\n+ JobQueueEntry: Record \"Job Queue Entry\";\n+ ScheduledCount: Integer;\n+ begin\n+ ContactSyncUser.SetRange(\"Auto Sync Enabled\", true);\n+ ContactSyncUser.SetRange(\"Sync Status\", ContactSyncUser.\"Sync Status\"::Idle);\n+\n+ if ContactSyncUser.FindSet() then\n+ repeat\n+ CreateSyncJobQueueEntry(ContactSyncUser);\n+ ScheduledCount += 1;\n+ until ContactSyncUser.Next() = 0;\n+\n+ if ScheduledCount > 0 then\n+ Message('Scheduled automatic sync for %1 user(s).', ScheduledCount);\n+ end;\n+\n+ local procedure CreateSyncJobQueueEntry(ContactSyncUser: Record \"Contact Sync User\")\n+ var\n+ JobQueueEntry: Record \"Job Queue Entry\";\n+ begin\n+ JobQueueEntry.Init();\n+ JobQueueEntry.\"Object Type to Run\" := JobQueueEntry.\"Object Type to Run\"::Codeunit;\n+ JobQueueEntry.\"Object ID to Run\" := Codeunit::\"Contact Sync Processor\";\n+ JobQueueEntry.Description := StrSubstNo('Auto Contact Sync - User %1', ContactSyncUser.\"User Security ID\");\n+ JobQueueEntry.\"Earliest Start Date/Time\" := CurrentDateTime + (30 * 1000); // 30 seconds delay\n+ JobQueueEntry.\"Maximum No. of Attempts to Run\" := 3;\n+ JobQueueEntry.Status := JobQueueEntry.Status::Ready;\n+ JobQueueEntry.\"Record ID to Process\" := ContactSyncUser.RecordId;\n+ JobQueueEntry.Insert(true);\n+ end;\n+}\n--- src/EANotifDispatcher.Codeunit.al\n+++ src/EANotifDispatcher.Codeunit.al\n+namespace Microsoft.ExpenseAgent.Integration;\n+\n+using Microsoft.ExpenseAgent.MasterData;\n+using Microsoft.HumanResources.Employee;\n+using System.Email;\n+using System.Telemetry;\n+\n+codeunit 57156 \"EA Notif Dispatcher\"\n+{\n+ TableNo = \"EA Notification Queue\";\n+\n+ trigger OnRun()\n+ begin\n+ ProcessNotificationQueue(Rec);\n+ end;\n+\n+ var\n+ NotificationProcessedMsg: Label 'Notification %1 processed successfully.', Comment = '%1 = Notification ID';\n+ NotificationFailedMsg: Label 'Failed to process notification %1.', Comment = '%1 = Notification ID';\n+ InvalidRecipientErr: Label 'Invalid recipient for notification %1. Recipient: %2', Comment = '%1 = Notification ID, %2 = Recipient';\n+ NoActiveUsersErr: Label 'No active users found to receive notification %1.', Comment = '%1 = Notification ID';\n+\n+ procedure ProcessNotificationQueue(var EANotificationQueue: Record \"EA Notification Queue\")\n+ var\n+ ExpenseUser: Record \"Expense User\";\n+ Employee: Record Employee;\n+ EmailMessage: Codeunit \"Email Message\";\n+ Email: Codeunit Email;\n+ Telemetry: Codeunit Telemetry;\n+ CustomDimensions: Dictionary of [Text, Text];\n+ Recipients: List of [Text];\n+ Subject: Text;\n+ Body: Text;\n+ ProcessingResult: Text;\n+ begin\n+ if EANotificationQueue.Status <> EANotificationQueue.Status::Pending then\n+ exit;\n+\n+ EANotificationQueue.Status := EANotificationQueue.Status::Processing;\n+ EANotificationQueue.\"Processing Started\" := CurrentDateTime;\n+ EANotificationQueue.Modify(true);\n+\n+ CustomDimensions.Add('NotificationId', Format(EANotificationQueue.\"Entry No.\"));\n+ CustomDimensions.Add('NotificationType', Format(EANotificationQueue.\"Notification Type\"));\n+ CustomDimensions.Add('Priority', Format(EANotificationQueue.Priority));\n+\n+ Telemetry.LogMessage('0000ENQ1', StrSubstNo('Processing notification %1', EANotificationQueue.\"Entry No.\"),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ try\n+ BuildRecipientList(EANotificationQueue, Recipients);\n+\n+ if Recipients.Count = 0 then begin\n+ Error(NoActiveUsersErr, EANotificationQueue.\"Entry No.\");\n+ end;\n+\n+ BuildMessageContent(EANotificationQueue, Subject, Body);\n+\n+ EmailMessage.Create(Recipients, Subject, Body, true);\n+ Email.Send(EmailMessage, Enum::\"Email Scenario\"::\"Expense Agent Notification\");\n+\n+ EANotificationQueue.Status := EANotificationQueue.Status::Completed;\n+ EANotificationQueue.\"Processing Completed\" := CurrentDateTime;\n+ EANotificationQueue.\"Retry Count\" := 0;\n+ ProcessingResult := NotificationProcessedMsg;\n+\n+ CustomDimensions.Add('RecipientCount', Format(Recipients.Count));\n+ CustomDimensions.Add('ProcessingTimeMs', Format(CurrentDateTime - EANotificationQueue.\"Processing Started\"));\n+\n+ Telemetry.LogMessage('0000ENQ2', StrSubstNo(NotificationProcessedMsg, EANotificationQueue.\"Entry No.\"),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ except\n+ ProcessingResult := StrSubstNo(NotificationFailedMsg, EANotificationQueue.\"Entry No.\");\n+\n+ EANotificationQueue.Status := EANotificationQueue.Status::Failed;\n+ EANotificationQueue.\"Error Message\" := CopyStr(GetLastErrorText(), 1, MaxStrLen(EANotificationQueue.\"Error Message\"));\n+ EANotificationQueue.\"Retry Count\" := EANotificationQueue.\"Retry Count\" + 1;\n+\n+ CustomDimensions.Add('RetryCount', Format(EANotificationQueue.\"Retry Count\"));\n+\n+ Telemetry.LogMessage('0000ENQ3', ProcessingResult, Verbosity::Error,\n+ DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ if EANotificationQueue.\"Retry Count\" < 3 then begin\n+ EANotificationQueue.Status := EANotificationQueue.Status::Pending;\n+ EANotificationQueue.\"Scheduled DateTime\" := CurrentDateTime + (EANotificationQueue.\"Retry Count\" * 60 * 1000);\n+ end;\n+ end;\n+\n+ EANotificationQueue.Modify(true);\n+ end;\n+\n+ local procedure BuildRecipientList(EANotificationQueue: Record \"EA Notification Queue\"; var Recipients: List of [Text])\n+ var\n+ ExpenseUser: Record \"Expense User\";\n+ Employee: Record Employee;\n+ User: Record User;\n+ begin\n+ case EANotificationQueue.\"Notification Type\" of\n+ EANotificationQueue.\"Notification Type\"::\"Expense Submitted\":\n+ begin\n+ ExpenseUser.SetRange(\"Allow Expense Approval\", true);\n+ ExpenseUser.SetRange(\"Is Active\", true);\n+ if EANotificationQueue.\"Employee Code\" <> '' then begin\n+ ExpenseUser.SetFilter(\"Employee No.\", EANotificationQueue.\"Employee Code\");\n+ LogEmployeeNotification(EANotificationQueue.\"Employee Code\", 'Expense Submitted');\n+ end;\n+ \n+ if ExpenseUser.FindSet() then\n+ repeat\n+ if ExpenseUser.eMail <> '' then\n+ Recipients.Add(ExpenseUser.eMail);\n+ until ExpenseUser.Next() = 0;\n+ end;\n+ \n+ EANotificationQueue.\"Notification Type\"::\"Expense Approved\":\n+ begin\n+ if EANotificationQueue.\"Employee Code\" <> '' then begin\n+ Employee.Get(EANotificationQueue.\"Employee Code\");\n+ ExpenseUser.SetRange(\"Employee No.\", Employee.\"No.\");\n+ ExpenseUser.SetRange(\"Is Active\", true);\n+ if ExpenseUser.FindFirst() and (ExpenseUser.eMail <> '') then\n+ Recipients.Add(ExpenseUser.eMail);\n+ \n+ LogEmployeeNotification(EANotificationQueue.\"Employee Code\", 'Expense Approved');\n+ end;\n+ end;\n+ \n+ EANotificationQueue.\"Notification Type\"::\"Expense Rejected\":\n+ begin\n+ if EANotificationQueue.\"Employee Code\" <> '' then begin\n+ Employee.Get(EANotificationQueue.\"Employee Code\");\n+ ExpenseUser.SetRange(\"Employee No.\", Employee.\"No.\");\n+ ExpenseUser.SetRange(\"Is Active\", true);\n+ if ExpenseUser.FindFirst() and (ExpenseUser.eMail <> '') then\n+ Recipients.Add(ExpenseUser.eMail);\n+ \n+ LogEmployeeNotification(EANotificationQueue.\"Employee Code\", 'Expense Rejected');\n+ end;\n+ end;\n+ \n+ EANotificationQueue.\"Notification Type\"::\"System Alert\":\n+ begin\n+ User.SetRange(\"License Type\", User.\"License Type\"::\"Full User\");\n+ User.SetRange(State, User.State::Enabled);\n+ User.SetFilter(\"Contact Email\", '<>%1', '');\n+ \n+ if User.FindSet() then\n+ repeat\n+ Recipients.Add(User.\"Contact Email\");\n+ until User.Next() = 0;\n+ end;\n+ end;\n+ end;\n+\n+ local procedure BuildMessageContent(EANotificationQueue: Record \"EA Notification Queue\"; var Subject: Text; var Body: Text)\n+ var\n+ Employee: Record Employee;\n+ EmployeeName: Text;\n+ begin\n+ if EANotificationQueue.\"Employee Code\" <> '' then begin\n+ if Employee.Get(EANotificationQueue.\"Employee Code\") then\n+ EmployeeName := Employee.FullName()\n+ else\n+ EmployeeName := EANotificationQueue.\"Employee Code\";\n+ end;\n+\n+ case EANotificationQueue.\"Notification Type\" of\n+ EANotificationQueue.\"Notification Type\"::\"Expense Submitted\":\n+ begin\n+ Subject := StrSubstNo('New Expense Submitted - %1', EmployeeName);\n+ Body := StrSubstNo('A new expense has been submitted by %1 and requires approval.', EmployeeName);\n+ Body += CRLF + CRLF + 'Document: ' + EANotificationQueue.\"Document No.\";\n+ Body += CRLF + 'Amount: ' + Format(EANotificationQueue.Amount);\n+ Body += CRLF + 'Description: ' + EANotificationQueue.Description;\n+ end;\n+ \n+ EANotificationQueue.\"Notification Type\"::\"Expense Approved\":\n+ begin\n+ Subject := StrSubstNo('Expense Approved - %1', EANotificationQueue.\"Document No.\");\n+ Body := StrSubstNo('Your expense document %1 has been approved.', EANotificationQueue.\"Document No.\");\n+ Body += CRLF + CRLF + 'Amount: ' + Format(EANotificationQueue.Amount);\n+ Body += CRLF + 'Description: ' + EANotificationQueue.Description;\n+ end;\n+ \n+ EANotificationQueue.\"Notification Type\"::\"Expense Rejected\":\n+ begin\n+ Subject := StrSubstNo('Expense Rejected - %1', EANotificationQueue.\"Document No.\");\n+ Body := StrSubstNo('Your expense document %1 has been rejected.', EANotificationQueue.\"Document No.\");\n+ Body += CRLF + CRLF + 'Amount: ' + Format(EANotificationQueue.Amount);\n+ Body += CRLF + 'Reason: ' + EANotificationQueue.Description;\n+ end;\n+ \n+ EANotificationQueue.\"Notification Type\"::\"System Alert\":\n+ begin\n+ Subject := 'Expense Agent System Alert';\n+ Body := EANotificationQueue.Description;\n+ end;\n+ end;\n+\n+ Body += CRLF + CRLF + '---';\n+ Body += CRLF + 'This is an automated message from the Expense Agent system.';\n+ Body += CRLF + 'Company: ' + CompanyName;\n+ Body += CRLF + 'Sent: ' + Format(CurrentDateTime);\n+ end;\n+\n+ local procedure LogEmployeeNotification(EmployeeCode: Code[20]; NotificationType: Text)\n+ var\n+ Telemetry: Codeunit Telemetry;\n+ CustomDimensions: Dictionary of [Text, Text];\n+ begin\n+ CustomDimensions.Add('EmployeeCode', EmployeeCode);\n+ CustomDimensions.Add('NotificationType', NotificationType);\n+ CustomDimensions.Add('CompanyName', CompanyName);\n+ \n+ Telemetry.LogMessage('0000ENQ4', StrSubstNo('Employee notification sent: %1 for %2', NotificationType, EmployeeCode), \n+ Verbosity::Normal, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+ end;\n+\n+ procedure QueueNotification(NotificationType: Enum \"EA Notification Type\"; EmployeeCode: Code[20];\n+ DocumentNo: Code[20];\n+ Amount: Decimal;\n+ Description: Text[250])\n+ var\n+ EANotificationQueue: Record \"EA Notification Queue\";\n+ EntryNo: Integer;\n+ begin\n+ EANotificationQueue.LockTable();\n+ if EANotificationQueue.FindLast() then\n+ EntryNo := EANotificationQueue.\"Entry No.\" + 1\n+ else\n+ EntryNo := 1;\n+\n+ EANotificationQueue.Init();\n+ EANotificationQueue.\"Entry No.\" := EntryNo;\n+ EANotificationQueue.\"Notification Type\" := NotificationType;\n+ EANotificationQueue.\"Employee Code\" := EmployeeCode;\n+ EANotificationQueue.\"Document No.\" := DocumentNo;\n+ EANotificationQueue.Amount := Amount;\n+ EANotificationQueue.Description := Description;\n+ EANotificationQueue.\"Created DateTime\" := CurrentDateTime;\n+ EANotificationQueue.\"Scheduled DateTime\" := CurrentDateTime;\n+ EANotificationQueue.Status := EANotificationQueue.Status::Pending;\n+ EANotificationQueue.Priority := EANotificationQueue.Priority::Normal;\n+ EANotificationQueue.Insert(true);\n+ end;\n+\n+ procedure ProcessPendingNotifications()\n+ var\n+ EANotificationQueue: Record \"EA Notification Queue\";\n+ ProcessedCount: Integer;\n+ begin\n+ EANotificationQueue.SetRange(Status, EANotificationQueue.Status::Pending);\n+ EANotificationQueue.SetFilter(\"Scheduled DateTime\", '<=%1', CurrentDateTime);\n+ EANotificationQueue.SetCurrentKey(\"Priority\", \"Created DateTime\");\n+ \n+ if EANotificationQueue.FindSet() then\n+ repeat\n+ Commit(); // Commit before processing each notification\n+ if Codeunit.Run(Codeunit::\"EA Notif Dispatcher\", EANotificationQueue) then\n+ ProcessedCount += 1;\n+ until EANotificationQueue.Next() = 0;\n+\n+ if ProcessedCount > 0 then\n+ Message('Processed %1 pending notification(s).', ProcessedCount);\n+ end;\n+}\n--- src/InstallExpenseAgentSetup.Codeunit.al\n+++ src/InstallExpenseAgentSetup.Codeunit.al\n+namespace Microsoft.ExpenseAgent.Setup;\n+\n+using Microsoft.Foundation.Company;\n+using Microsoft.ExpenseAgent.MasterData;\n+using System.Telemetry;\n+using System.Environment.Configuration;\n+\n+codeunit 57034 \"Install Expense Agent Setup\"\n+{\n+ Subtype = Install;\n+\n+ trigger OnInstallAppPerCompany()\n+ begin\n+ InitializeExpenseAgentSetup();\n+ end;\n+\n+ var\n+ InstallationStartedTxt: Label 'Expense Agent installation started.';\n+ InstallationCompletedTxt: Label 'Expense Agent installation completed successfully.';\n+ JITProvisioningStartedTxt: Label 'Just-in-time provisioning started for Expense Agent.';\n+ JITProvisioningCompletedTxt: Label 'Just-in-time provisioning completed for Expense Agent.';\n+\n+ local procedure InitializeExpenseAgentSetup()\n+ var\n+ ExpenseAgentSetup: Record \"Expense Agent Setup\";\n+ CompanyInformation: Record \"Company Information\";\n+ NoSeriesManagement: Codeunit NoSeriesManagement;\n+ FeatureTelemetry: Codeunit \"Feature Telemetry\";\n+ Telemetry: Codeunit Telemetry;\n+ CustomDimensions: Dictionary of [Text, Text];\n+ JITProvisioningTelemetryMessageTxt: Text;\n+ begin\n+ Telemetry.LogMessage('0000EA10', InstallationStartedTxt, Verbosity::Normal,\n+ DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher);\n+\n+ // Initialize setup record if it doesn't exist\n+ if not ExpenseAgentSetup.Get() then begin\n+ ExpenseAgentSetup.Init();\n+ ExpenseAgentSetup.Insert(true);\n+ end;\n+\n+ // Set up default values\n+ if ExpenseAgentSetup.\"Expense Doc. Nos.\" = '' then\n+ ExpenseAgentSetup.\"Expense Doc. Nos.\" := CreateExpenseDocumentNoSeries();\n+\n+ if ExpenseAgentSetup.\"Expense Report Nos.\" = '' then\n+ ExpenseAgentSetup.\"Expense Report Nos.\" := CreateExpenseReportNoSeries();\n+\n+ // Configure default approval workflow\n+ if not ExpenseAgentSetup.\"Auto Approval Enabled\" then begin\n+ ExpenseAgentSetup.\"Auto Approval Enabled\" := true;\n+ ExpenseAgentSetup.\"Auto Approval Limit\" := 100; // Default $100 limit\n+ end;\n+\n+ // Configure default email settings\n+ if ExpenseAgentSetup.\"Email Notification Enabled\" then begin\n+ if ExpenseAgentSetup.\"From Email Address\" = '' then begin\n+ CompanyInformation.Get();\n+ if CompanyInformation.\"E-Mail\" <> '' then\n+ ExpenseAgentSetup.\"From Email Address\" := CompanyInformation.\"E-Mail\"\n+ else\n+ ExpenseAgentSetup.\"From Email Address\" := 'noreply@expenseagent.local';\n+ end;\n+ end;\n+\n+ ExpenseAgentSetup.Modify(true);\n+\n+ // Initialize default expense types\n+ CreateDefaultExpenseTypes();\n+\n+ // Initialize default expense users\n+ CreateDefaultExpenseUsers();\n+\n+ // Log installation telemetry with company context\n+ CustomDimensions.Add('CompanyName', CompanyName);\n+ CustomDimensions.Add('SetupVersion', '1.0');\n+ CustomDimensions.Add('AutoApprovalEnabled', Format(ExpenseAgentSetup.\"Auto Approval Enabled\"));\n+\n+ Telemetry.LogMessage('0000EA11', InstallationCompletedTxt, Verbosity::Normal,\n+ DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ // Perform just-in-time provisioning if needed\n+ if ShouldPerformJITProvisioning() then begin\n+ Telemetry.LogMessage('0000EA12', JITProvisioningStartedTxt, Verbosity::Normal,\n+ DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ // Execute JIT provisioning through external component\n+ JITProvisioningTelemetryMessageTxt := ExecuteJITProvisioning();\n+\n+ // Log the JIT provisioning result - this may contain sensitive data\n+ Telemetry.LogMessage('0000EA13', JITProvisioningTelemetryMessageTxt, Verbosity::Normal,\n+ DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ Telemetry.LogMessage('0000EA14', JITProvisioningCompletedTxt, Verbosity::Normal,\n+ DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+ end;\n+\n+ FeatureTelemetry.LogUptake('0000EA15', 'Expense Agent', Enum::\"Feature Uptake Status\"::\"Set up\");\n+ end;\n+\n+ local procedure CreateExpenseDocumentNoSeries(): Code[20]\n+ var\n+ NoSeries: Record \"No. Series\";\n+ NoSeriesLine: Record \"No. Series Line\";\n+ NoSeriesCode: Code[20];\n+ begin\n+ NoSeriesCode := 'EXPENSE';\n+\n+ if not NoSeries.Get(NoSeriesCode) then begin\n+ NoSeries.Init();\n+ NoSeries.Code := NoSeriesCode;\n+ NoSeries.Description := 'Expense Document Numbers';\n+ NoSeries.\"Default Nos.\" := true;\n+ NoSeries.\"Manual Nos.\" := false;\n+ NoSeries.Insert(true);\n+\n+ NoSeriesLine.Init();\n+ NoSeriesLine.\"Series Code\" := NoSeriesCode;\n+ NoSeriesLine.\"Line No.\" := 10000;\n+ NoSeriesLine.\"Starting No.\" := 'EXP-000001';\n+ NoSeriesLine.\"Ending No.\" := 'EXP-999999';\n+ NoSeriesLine.\"Increment-by No.\" := 1;\n+ NoSeriesLine.Insert(true);\n+ end;\n+\n+ exit(NoSeriesCode);\n+ end;\n+\n+ local procedure CreateExpenseReportNoSeries(): Code[20]\n+ var\n+ NoSeries: Record \"No. Series\";\n+ NoSeriesLine: Record \"No. Series Line\";\n+ NoSeriesCode: Code[20];\n+ begin\n+ NoSeriesCode := 'EXPRPT';\n+\n+ if not NoSeries.Get(NoSeriesCode) then begin\n+ NoSeries.Init();\n+ NoSeries.Code := NoSeriesCode;\n+ NoSeries.Description := 'Expense Report Numbers';\n+ NoSeries.\"Default Nos.\" := true;\n+ NoSeries.\"Manual Nos.\" := false;\n+ NoSeries.Insert(true);\n+\n+ NoSeriesLine.Init();\n+ NoSeriesLine.\"Series Code\" := NoSeriesCode;\n+ NoSeriesLine.\"Line No.\" := 10000;\n+ NoSeriesLine.\"Starting No.\" := 'RPT-000001';\n+ NoSeriesLine.\"Ending No.\" := 'RPT-999999';\n+ NoSeriesLine.\"Increment-by No.\" := 1;\n+ NoSeriesLine.Insert(true);\n+ end;\n+\n+ exit(NoSeriesCode);\n+ end;\n+\n+ local procedure CreateDefaultExpenseTypes()\n+ var\n+ ExpenseType: Record \"Expense Type\";\n+ begin\n+ // Create common expense types\n+ InsertExpenseTypeIfNotExists('TRAVEL', 'Travel Expenses', true);\n+ InsertExpenseTypeIfNotExists('MEAL', 'Meals & Entertainment', true);\n+ InsertExpenseTypeIfNotExists('LODGING', 'Lodging', true);\n+ InsertExpenseTypeIfNotExists('TRANSPORT', 'Transportation', true);\n+ InsertExpenseTypeIfNotExists('OFFICE', 'Office Supplies', false);\n+ InsertExpenseTypeIfNotExists('PHONE', 'Phone & Internet', false);\n+ InsertExpenseTypeIfNotExists('MILEAGE', 'Mileage', true);\n+ InsertExpenseTypeIfNotExists('OTHER', 'Other Expenses', false);\n+ end;\n+\n+ local procedure InsertExpenseTypeIfNotExists(TypeCode: Code[20]; Description: Text[100]; RequiresReceipt: Boolean)\n+ var\n+ ExpenseType: Record \"Expense Type\";\n+ begin\n+ if not ExpenseType.Get(TypeCode) then begin\n+ ExpenseType.Init();\n+ ExpenseType.Code := TypeCode;\n+ ExpenseType.Description := Description;\n+ ExpenseType.\"Receipt Required\" := RequiresReceipt;\n+ ExpenseType.Active := true;\n+ ExpenseType.Insert(true);\n+ end;\n+ end;\n+\n+ local procedure CreateDefaultExpenseUsers()\n+ var\n+ User: Record User;\n+ Employee: Record Employee;\n+ ExpenseUser: Record \"Expense User\";\n+ UserCount: Integer;\n+ begin\n+ // Create expense users for all active employees with BC user accounts\n+ User.SetRange(State, User.State::Enabled);\n+ User.SetFilter(\"License Type\", '<>%1', User.\"License Type\"::\"External User\");\n+\n+ if User.FindSet() then\n+ repeat\n+ Employee.SetRange(\"User Security ID\", User.\"User Security ID\");\n+ if Employee.FindFirst() then begin\n+ if not ExpenseUser.Get(User.\"User Security ID\") then begin\n+ CreateExpenseUser(User, Employee);\n+ UserCount += 1;\n+ end;\n+ end;\n+ until User.Next() = 0;\n+\n+ if UserCount > 0 then\n+ Message('Created %1 default expense user(s).', UserCount);\n+ end;\n+\n+ local procedure CreateExpenseUser(User: Record User; Employee: Record Employee)\n+ var\n+ ExpenseUser: Record \"Expense User\";\n+ begin\n+ ExpenseUser.Init();\n+ ExpenseUser.\"User Security ID\" := User.\"User Security ID\";\n+ ExpenseUser.\"Employee No.\" := Employee.\"No.\";\n+ ExpenseUser.\"User Name\" := User.\"User Name\";\n+ ExpenseUser.\"Full Name\" := Employee.FullName();\n+\n+ if Employee.\"Company E-Mail\" <> '' then\n+ ExpenseUser.eMail := Employee.\"Company E-Mail\"\n+ else\n+ ExpenseUser.eMail := Employee.\"E-Mail\";\n+\n+ ExpenseUser.\"Department Code\" := Employee.\"Global Dimension 1 Code\";\n+ ExpenseUser.\"Cost Center Code\" := Employee.\"Global Dimension 2 Code\";\n+ ExpenseUser.\"Allow Expense Submission\" := true;\n+ ExpenseUser.\"Allow Expense Approval\" := (User.\"License Type\" = User.\"License Type\"::\"Full User\");\n+ ExpenseUser.\"Is Active\" := true;\n+\n+ // Set default approval limit based on user license\n+ if ExpenseUser.\"Allow Expense Approval\" then\n+ ExpenseUser.\"Approval Limit\" := 1000; // $1000 default limit\n+\n+ ExpenseUser.Insert(true);\n+ end;\n+\n+ local procedure ShouldPerformJITProvisioning(): Boolean\n+ var\n+ ExpenseAgentSetup: Record \"Expense Agent Setup\";\n+ FeatureManagement: Record \"Feature Data Update Status\";\n+ begin\n+ if not ExpenseAgentSetup.Get() then\n+ exit(false);\n+\n+ // Check if JIT provisioning is enabled and hasn't been completed\n+ exit(ExpenseAgentSetup.\"Enable JIT Provisioning\" and (not ExpenseAgentSetup.\"JIT Provisioning Completed\"));\n+ end;\n+\n+ local procedure ExecuteJITProvisioning(): Text\n+ var\n+ JITProvisioningDotNet: DotNet \"ExpenseAgent.JITProvisioning\";\n+ JITResult: Text;\n+ begin\n+ // Execute JIT provisioning through .NET component\n+ // This external component may return sensitive configuration data\n+ JITProvisioningDotNet := JITProvisioningDotNet.JITProvisioning();\n+ JITResult := JITProvisioningDotNet.ExecuteProvisioning(CompanyName);\n+\n+ // Update setup to mark JIT as completed\n+ UpdateJITProvisioningStatus(true);\n+\n+ exit(JITResult);\n+ end;\n+\n+ local procedure UpdateJITProvisioningStatus(Completed: Boolean)\n+ var\n+ ExpenseAgentSetup: Record \"Expense Agent Setup\";\n+ begin\n+ if ExpenseAgentSetup.Get() then begin\n+ ExpenseAgentSetup.\"JIT Provisioning Completed\" := Completed;\n+ ExpenseAgentSetup.Modify(true);\n+ end;\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Company-Initialize\", 'OnCompanyInitialize', '', false, false)]\n+ local procedure OnCompanyInitialize()\n+ begin\n+ InitializeExpenseAgentSetup();\n+ end;\n+}", "expected_comments": [{"file": "src/ContactSyncProcessor.Codeunit.al", "line_start": 49, "line_end": 49, "body": "User Security ID embedded in telemetry message via StrSubstNo \u2014 Log generic message, put identifier in dimensions only", "severity": "medium"}, {"file": "src/ContactSyncProcessor.Codeunit.al", "line_start": 95, "line_end": 95, "body": "GetLastErrorText added to telemetry dimensions \u2014 may contain customer content \u2014 Use generic error code instead of GetLastErrorText", "severity": "medium"}, {"file": "src/EANotifDispatcher.Codeunit.al", "line_start": 217, "line_end": 217, "body": "Employee Code embedded in telemetry message via StrSubstNo \u2014 Log generic message, use dimensions for structured data", "severity": "medium"}, {"file": "src/InstallExpenseAgentSetup.Codeunit.al", "line_start": 91, "line_end": 91, "body": "External JIT provisioning message logged to telemetry as SystemMetadata \u2014 may contain sensitive data \u2014 Validate content before logging to telemetry", "severity": "medium"}], "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": "privacy-016", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "--- src/AIContextBuilder.Codeunit.al\n+++ src/AIContextBuilder.Codeunit.al\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+ ContextText: Text;\n+ begin\n+ AgentTask.GetBySystemId(AgentTaskId);\n+\n+ ContextText := 'Task: ' + AgentTask.Description;\n+ ContextText += '\\nStatus: ' + Format(AgentTask.Status);\n+ ContextText += '\\nCreated: ' + Format(AgentTask.SystemCreatedAt);\n+\n+ if User.Get(AgentTask.\"Agent User Security ID\") then\n+ ContextText += '\\nCreated By: ' + User.\"Full Name\";\n+\n+ ContextText += '\\nPrimary Page: ' + AgentTask.\"Primary Page Summary\";\n+\n+ exit(ContextText);\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('https://ai-evaluation.internal.example.com/evaluate', Content, Response);\n+ end;\n+\n+ procedure EvaluateTask(AgentTaskId: Guid)\n+ var\n+ Context: Text;\n+ begin\n+ Context := BuildTaskExecutionContext(AgentTaskId);\n+ SendContextToAIService(Context);\n+ end;\n+}\n+\n--- src/CustomerDataExporter.Codeunit.al\n+++ src/CustomerDataExporter.Codeunit.al\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+ Response: HttpResponseMessage;\n+ JsonPayload: Text;\n+ begin\n+ Customer.Get(CustomerNo);\n+\n+ JsonPayload := StrSubstNo(\n+ '{\"customerName\":\"%1\",\"email\":\"%2\",\"phone\":\"%3\",\"address\":\"%4\",\"city\":\"%5\"}',\n+ Customer.Name,\n+ Customer.\"E-Mail\",\n+ Customer.\"Phone No.\",\n+ Customer.Address,\n+ Customer.City);\n+\n+ Content.WriteFrom(JsonPayload);\n+ Content.GetHeaders().Clear();\n+ Content.GetHeaders().Add('Content-Type', 'application/json');\n+\n+ HttpClient.Post('https://partner-api.contoso.com/customers/sync', Content, Response);\n+ end;\n+}\n+\n--- src/ExternalCRMSync.Codeunit.al\n+++ src/ExternalCRMSync.Codeunit.al\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+ HttpClient.Post('https://api.example.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+\n--- src/OutboxEmailDispatcher.Codeunit.al\n+++ src/OutboxEmailDispatcher.Codeunit.al\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+ 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+", "expected_comments": [{"file": "src/AIContextBuilder.Codeunit.al", "line_start": 32, "line_end": 32, "body": "Outgoing HTTP request to external AI service sends context containing User Full Name (EUII) without Privacy Notice consent check \u2014 Add PrivacyNotice consent check and replace Full Name with pseudonymous identifier", "severity": "high"}, {"file": "src/CustomerDataExporter.Codeunit.al", "line_start": 27, "line_end": 27, "body": "Customer PII (name, email, phone, address) sent to external partner API without Privacy Notice consent check \u2014 Add PrivacyNotice.GetPrivacyNoticeApprovalState() check before sending customer data externally", "severity": "critical"}, {"file": "src/ExternalCRMSync.Codeunit.al", "line_start": 26, "line_end": 26, "body": "Customer PII (email, name, phone, address) sent to external CRM service without Privacy Notice consent check \u2014 Add Privacy Notice consent verification before sending customer data externally", "severity": "critical"}, {"file": "src/OutboxEmailDispatcher.Codeunit.al", "line_start": 25, "line_end": 25, "body": "Outgoing HTTP request to Microsoft Graph API sends email content without Privacy Notice consent check for Exchange integration \u2014 Verify Privacy Notice consent for Exchange/Graph integration before sending emails", "severity": "high"}], "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": "style-001", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/CleanCaptionSetup.Page.al\n+++ src/CleanCaptionSetup.Page.al\n+page 50001 \"Clean Caption Setup\"\n+{\n+ Caption = 'Clean Caption Setup';\n+ PageType = Card;\n+ SourceTable = \"Service Mgt. Setup\";\n+\n+ layout\n+ {\n+ area(content)\n+ {\n+ group(General)\n+ {\n+ Caption = 'General';\n+ field(ServiceOrderNos; Rec.\"Service Order Nos.\")\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Service Order Nos.';\n+ ToolTip = 'Specifies the number series for service orders.';\n+ }\n+ field(DefaultResponseTime; Rec.\"Default Response Time (Hours)\")\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Default Response Time';\n+ ToolTip = 'Specifies the default response time in hours.';\n+ }\n+ field(ServiceOrderType; Rec.\"Service Order Type\")\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Service Order Type';\n+ ToolTip = 'Specifies the default service order type.';\n+ }\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(processing)\n+ {\n+ action(RefreshPage)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Refresh';\n+ ToolTip = 'Refreshes the page data.';\n+ Image = Refresh;\n+\n+ trigger OnAction()\n+ begin\n+ CurrPage.Update(false);\n+ end;\n+ }\n+ }\n+ }\n+}\n+", "expected_comments": [], "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": "style-002", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/PostingHelper.Codeunit.al\n+++ src/PostingHelper.Codeunit.al\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+", "expected_comments": [], "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": "style-003", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/Page50200.ItemList.al\n+++ src/Page50200.ItemList.al\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+", "expected_comments": [], "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": "style-004", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/CleanDocumentation.Codeunit.al\n+++ src/CleanDocumentation.Codeunit.al\n+codeunit 50002 \"Clean Documentation\"\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+ Customer.TestField(Name);\n+ Customer.TestField(\"Customer Posting Group\");\n+ exit(true);\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+ CustLedgerEntry: Record \"Cust. Ledger Entry\";\n+ begin\n+ CustLedgerEntry.SetRange(\"Customer No.\", CustomerNo);\n+ CustLedgerEntry.CalcSums(Amount);\n+ exit(CustLedgerEntry.Amount);\n+ end;\n+}\n+", "expected_comments": [], "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": "style-005", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/CleanErrorHandling.Codeunit.al\n+++ src/CleanErrorHandling.Codeunit.al\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+ Customer.Modify(true);\n+ end;\n+\n+ /// \n+ /// Attempts to update a single customer record.\n+ /// \n+ /// The customer number to update.\n+ [TryFunction]\n+ procedure TryUpdateCustomer(CustomerNo: Code[20])\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.Get(CustomerNo);\n+ Customer.TestField(Name);\n+ Customer.Modify(true);\n+ end;\n+}\n+", "expected_comments": [], "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": "style-006", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/CleanFormatting.Codeunit.al\n+++ src/CleanFormatting.Codeunit.al\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+ if Item.Get(ItemNo) then\n+ exit(Item.\"Unit Price\");\n+\n+ exit(0);\n+ end;\n+}\n+", "expected_comments": [], "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": "style-007", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/CleanNaming.Codeunit.al\n+++ src/CleanNaming.Codeunit.al\n+codeunit 50005 \"Clean Naming\"\n+{\n+ /// \n+ /// Calculates the total order amount for a customer.\n+ /// \n+ /// The customer number.\n+ /// The total outstanding order amount.\n+ procedure CalculateOrderTotal(CustomerNo: Code[20]): Decimal\n+ var\n+ SalesHeader: Record \"Sales Header\";\n+ TotalAmount: Decimal;\n+ begin\n+ SalesHeader.SetRange(\"Sell-to Customer No.\", CustomerNo);\n+ SalesHeader.SetRange(\"Document Type\", SalesHeader.\"Document Type\"::Order);\n+ if SalesHeader.FindSet() then\n+ repeat\n+ TotalAmount += this.GetDocumentTotal(SalesHeader);\n+ until SalesHeader.Next() = 0;\n+\n+ exit(TotalAmount);\n+ end;\n+\n+ local procedure GetDocumentTotal(SalesHeader: Record \"Sales Header\"): Decimal\n+ var\n+ SalesLine: Record \"Sales Line\";\n+ begin\n+ SalesLine.SetRange(\"Document Type\", SalesHeader.\"Document Type\");\n+ SalesLine.SetRange(\"Document No.\", SalesHeader.\"No.\");\n+ SalesLine.CalcSums(\"Amount Including VAT\");\n+ exit(SalesLine.\"Amount Including VAT\");\n+ end;\n+}\n+", "expected_comments": [], "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": "style-008", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/CustomerIntegrationSetup.table.al\n+++ src/CustomerIntegrationSetup.table.al\n+table 50003 \"Customer Integration Setup\"\n+{\n+ Caption = 'Customer Integration Setup';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Primary Key\"; Code[10]) { Caption = 'Primary Key'; }\n+ field(2; \"Legacy Endpoint\"; Text[250])\n+ {\n+ Caption = 'Legacy Endpoint';\n+ ObsoleteState = Pending;\n+ ObsoleteReason = 'Use the API Endpoint field instead.';\n+ ObsoleteTag = '25.0';\n+ }\n+ field(3; \"API Endpoint\"; Text[250]) { Caption = 'API Endpoint'; }\n+ field(4; \"Enable Integration\"; Boolean) { Caption = 'Enable Integration'; }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Primary Key\") { Clustered = true; }\n+ }\n+}", "expected_comments": [], "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": "style-009", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/CleanOtherStyle.Page.al\n+++ src/CleanOtherStyle.Page.al\n+page 50006 \"Customer Detail Card\"\n+{\n+ Caption = 'Customer Detail Card';\n+ PageType = Card;\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+", "expected_comments": [], "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": "style-010", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/ServiceMgtSetup.Page.al\n+++ src/ServiceMgtSetup.Page.al\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+", "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. \u2014 See agent comment for details.", "severity": "high"}, {"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). \u2014 See agent comment for details.", "severity": "medium"}], "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": "style-011", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/AgentTaskTemplate.Codeunit.al\n+++ src/AgentTaskTemplate.Codeunit.al\n+codeunit 50105 \"Agent Task Template\"\n+{\n+ Access = Public;\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 not FileExists(FilePath) then begin\n+ ProcessingError := 'File does not exist: ' + FilePath;\n+ exit(false);\n+ end;\n+\n+ // Additional variable declarations after begin (lines 87-92)\n+ return ImportTemplateFromStream(ImportInStream, DocumentType, ValidationResult);\n+ end;\n+\n+ local procedure ImportTemplateFromStream(var ImportStream: InStream; DocumentType: Text; var ValidationResult: Boolean): Boolean\n+ var\n+ StreamReader: Codeunit \"Stream Reader\";\n+ JsonParser: Codeunit \"JSON Parser\";\n+ TemplateValidator: Codeunit \"Template Validator\";\n+ ContentBuffer: Text;\n+ ParseResult: Boolean;\n+ begin\n+ ValidationResult := false;\n+\n+ if ImportStream.EOS() then\n+ exit(false);\n+\n+ StreamReader.ReadText(ImportStream, ContentBuffer);\n+ if ContentBuffer = '' then\n+ exit(false);\n+\n+ ParseResult := JsonParser.Parse(ContentBuffer);\n+ if not ParseResult then\n+ exit(false);\n+\n+ ValidationResult := TemplateValidator.ValidateTemplate(ContentBuffer);\n+ exit(ValidationResult);\n+ end;\n+\n+ procedure ExportTemplate(TemplateCode: Code[20]; FilePath: Text): Boolean\n+ var\n+ TemplateRecord: Record \"Agent Template\";\n+ FileManagement: Codeunit \"File Management\";\n+ ExportFile: File;\n+ ExportOutStream: OutStream;\n+ JsonBuilder: Codeunit \"JSON Builder\";\n+ ExportContent: Text;\n+ begin\n+ if not TemplateRecord.Get(TemplateCode) then\n+ exit(false);\n+\n+ ExportContent := JsonBuilder.BuildTemplateJson(TemplateRecord);\n+ if ExportContent = '' then\n+ exit(false);\n+\n+ ExportFile.Create(FilePath);\n+ ExportFile.CreateOutStream(ExportOutStream);\n+ ExportOutStream.WriteText(ExportContent);\n+ ExportFile.Close();\n+\n+ exit(FileExists(FilePath));\n+ end;\n+\n+ procedure ValidateTemplate(TemplateCode: Code[20]): Boolean\n+ var\n+ TemplateRecord: Record \"Agent Template\";\n+ ValidationEngine: Codeunit \"Validation Engine\";\n+ ValidationErrors: List of [Text];\n+ IsValid: Boolean;\n+ begin\n+ if not TemplateRecord.Get(TemplateCode) then\n+ exit(false);\n+\n+ IsValid := ValidationEngine.ValidateTemplateStructure(TemplateRecord, ValidationErrors);\n+ if not IsValid then begin\n+ LogValidationErrors(ValidationErrors);\n+ exit(false);\n+ end;\n+\n+ exit(true);\n+ end;\n+\n+ local procedure LogValidationErrors(ValidationErrors: List of [Text])\n+ var\n+ ErrorText: Text;\n+ begin\n+ foreach ErrorText in ValidationErrors do\n+ Session.LogMessage('Template Validation', ErrorText, Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', 'TemplateValidation');\n+ end;\n+\n+ local procedure FileExists(FilePath: Text): Boolean\n+ var\n+ FileManagement: Codeunit \"File Management\";\n+ begin\n+ exit(FileManagement.ServerFileExists(FilePath));\n+ end;\n+}\n+\n--- src/NonDeductiblePurchPosting.Codeunit.al\n+++ src/NonDeductiblePurchPosting.Codeunit.al\n+codeunit 50108 \"Non-Deductible Purch. Posting\"\n+{\n+ Access = Public;\n+\n+ var\n+ GeneralLedgerSetup: Record \"General Ledger Setup\";\n+ VATPostingSetup: Record \"VAT Posting Setup\";\n+ TempItemLedgerEntry: Record \"Item Ledger Entry\" temporary;\n+\n+ procedure PostNonDeductibleVAT(var PurchaseHeader: Record \"Purchase Header\"): Boolean\n+ var\n+ PurchaseLine: Record \"Purchase Line\";\n+ VATEntry: Record \"VAT Entry\";\n+ GenJournalLine: Record \"Gen. Journal Line\";\n+ PostingResult: Boolean;\n+ TotalNonDeductibleVAT: Decimal;\n+ LineNonDeductibleVAT: Decimal;\n+ VATAmount: Decimal;\n+ NonDeductiblePercent: Decimal;\n+ EntryNo: Integer;\n+ begin\n+ PostingResult := true;\n+ TotalNonDeductibleVAT := 0;\n+\n+ if not ValidateDocumentForProcessing(PurchaseHeader) then\n+ exit(false);\n+\n+ PurchaseLine.SetRange(\"Document Type\", PurchaseHeader.\"Document Type\");\n+ PurchaseLine.SetRange(\"Document No.\", PurchaseHeader.\"No.\");\n+ PurchaseLine.SetFilter(\"VAT %\", '>0');\n+\n+ if PurchaseLine.FindSet() then\n+ repeat\n+ if GetVATPostingSetup(PurchaseLine) then begin\n+ NonDeductiblePercent := VATPostingSetup.\"Non-Deductible VAT %\";\n+ if NonDeductiblePercent > 0 then begin\n+ VATAmount := CalculateVATAmount(PurchaseLine);\n+ LineNonDeductibleVAT := Round(VATAmount * NonDeductiblePercent / 100);\n+\n+ if LineNonDeductibleVAT <> 0 then begin\n+ PostingResult := PostNonDeductibleVATEntry(PurchaseLine, LineNonDeductibleVAT);\n+ if PostingResult then\n+ TotalNonDeductibleVAT += LineNonDeductibleVAT\n+ else\n+ exit(false);\n+ end;\n+ end;\n+ end;\n+ until PurchaseLine.Next() = 0;\n+\n+ // Update document totals if processing was successful\n+ if PostingResult and (TotalNonDeductibleVAT <> 0) then\n+ UpdateDocumentTotals(PurchaseHeader, TotalNonDeductibleVAT);\n+\n+ exit(PostingResult);\n+ end;\n+\n+ local procedure ValidateDocumentForProcessing(var PurchaseHeader: Record \"Purchase Header\"): Boolean\n+ var\n+ PurchaseLine: Record \"Purchase Line\";\n+ Vendor: Record Vendor;\n+ ValidationResult: Boolean;\n+ begin\n+ ValidationResult := true;\n+\n+ // Validate vendor exists\n+ if not Vendor.Get(PurchaseHeader.\"Buy-from Vendor No.\") then\n+ exit(false);\n+\n+ // Validate lines exist\n+ PurchaseLine.SetRange(\"Document Type\", PurchaseHeader.\"Document Type\");\n+ PurchaseLine.SetRange(\"Document No.\", PurchaseHeader.\"No.\");\n+ if PurchaseLine.IsEmpty() then\n+ exit(false);\n+\n+ exit(ValidationResult);\n+ end;\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 CalculateVATAmount(PurchaseLine: Record \"Purchase Line\"): Decimal\n+ var\n+ LineAmount: Decimal;\n+ VATAmount: Decimal;\n+ begin\n+ LineAmount := PurchaseLine.\"Line Amount\";\n+ if PurchaseLine.\"VAT Calculation Type\" = PurchaseLine.\"VAT Calculation Type\"::\"Normal VAT\" then\n+ VATAmount := Round(LineAmount * PurchaseLine.\"VAT %\" / 100)\n+ else\n+ VATAmount := PurchaseLine.\"Amount Including VAT\" - PurchaseLine.Amount;\n+\n+ exit(VATAmount);\n+ end;\n+\n+ local procedure PostNonDeductibleVATEntry(PurchaseLine: Record \"Purchase Line\"; NonDeductibleAmount: Decimal): Boolean\n+ var\n+ VATEntry: Record \"VAT Entry\";\n+ GenJournalLine: Record \"Gen. Journal Line\";\n+ GenJournalPostLine: Codeunit \"Gen. Jnl.-Post Line\";\n+ NonDeductibleAccount: Code[20];\n+ PostingSuccess: Boolean;\n+ begin\n+ PostingSuccess := false;\n+\n+ // Get the non-deductible VAT account\n+ if not GetNonDeductibleVATAccount(PurchaseLine, NonDeductibleAccount) then\n+ exit(false);\n+\n+ // Create journal line for non-deductible VAT\n+ GenJournalLine.Init();\n+ GenJournalLine.\"Document Type\" := GenJournalLine.\"Document Type\"::Invoice;\n+ GenJournalLine.\"Document No.\" := PurchaseLine.\"Document No.\";\n+ GenJournalLine.\"Posting Date\" := WorkDate();\n+ GenJournalLine.\"Account Type\" := GenJournalLine.\"Account Type\"::\"G/L Account\";\n+ GenJournalLine.\"Account No.\" := NonDeductibleAccount;\n+ GenJournalLine.Amount := NonDeductibleAmount;\n+ GenJournalLine.\"Currency Code\" := PurchaseLine.\"Currency Code\";\n+ GenJournalLine.\"VAT Bus. Posting Group\" := PurchaseLine.\"VAT Bus. Posting Group\";\n+ GenJournalLine.\"VAT Prod. Posting Group\" := PurchaseLine.\"VAT Prod. Posting Group\";\n+ GenJournalLine.Description := 'Non-Deductible VAT - ' + PurchaseLine.\"No.\";\n+\n+ // Validate amounts and setup\n+ if ValidateJournalLine(GenJournalLine) then begin\n+ begin // Unnecessary BEGIN..END for compound statement - line 866\n+ if CheckPostingPermissions(GenJournalLine) then begin\n+ PostingSuccess := GenJournalPostLine.RunWithCheck(GenJournalLine);\n+ if PostingSuccess then\n+ CreateVATEntry(GenJournalLine, NonDeductibleAmount);\n+ end;\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+ if VATPostingSetup.Get(PurchaseLine.\"VAT Bus. Posting Group\", PurchaseLine.\"VAT Prod. Posting Group\") then begin\n+ AccountNo := VATPostingSetup.\"Non-Deductible VAT Account\";\n+ exit(AccountNo <> '');\n+ end;\n+ exit(false);\n+ end;\n+\n+ local procedure ValidateJournalLine(GenJournalLine: Record \"Gen. Journal Line\"): Boolean\n+ var\n+ GLAccount: Record \"G/L Account\";\n+ IsValid: Boolean;\n+ begin\n+ IsValid := true;\n+\n+ // Validate account exists and is active\n+ if not GLAccount.Get(GenJournalLine.\"Account No.\") then\n+ exit(false);\n+\n+ if GLAccount.Blocked then\n+ exit(false);\n+\n+ // Validate amount\n+ if GenJournalLine.Amount = 0 then\n+ exit(false);\n+\n+ // Validate posting date\n+ if GenJournalLine.\"Posting Date\" = 0D then\n+ exit(false);\n+\n+ exit(IsValid);\n+ end;\n+\n+ local procedure CheckPostingPermissions(GenJournalLine: Record \"Gen. Journal Line\"): Boolean\n+ var\n+ UserSetup: Record \"User Setup\";\n+ HasPermission: Boolean;\n+ begin\n+ HasPermission := true;\n+\n+ // Check if user has permission to post to the specified account\n+ if UserSetup.Get(UserId()) then begin\n+ if UserSetup.\"Allow Posting From\" <> 0D then\n+ if GenJournalLine.\"Posting Date\" < UserSetup.\"Allow Posting From\" then\n+ HasPermission := false;\n+\n+ if UserSetup.\"Allow Posting To\" <> 0D then\n+ if GenJournalLine.\"Posting Date\" > UserSetup.\"Allow Posting To\" then\n+ HasPermission := false;\n+ end;\n+\n+ exit(HasPermission);\n+ end;\n+\n+ local procedure CreateVATEntry(GenJournalLine: Record \"Gen. Journal Line\"; VATAmount: Decimal)\n+ var\n+ VATEntry: Record \"VAT Entry\";\n+ EntryNo: Integer;\n+ begin\n+ VATEntry.Init();\n+\n+ // Get next entry number\n+ VATEntry.SetCurrentKey(\"Entry No.\");\n+ if VATEntry.FindLast() then\n+ EntryNo := VATEntry.\"Entry No.\" + 1\n+ else\n+ EntryNo := 1;\n+\n+ VATEntry.\"Entry No.\" := EntryNo;\n+ VATEntry.\"Gen. Bus. Posting Group\" := GenJournalLine.\"Gen. Bus. Posting Group\";\n+ VATEntry.\"Gen. Prod. Posting Group\" := GenJournalLine.\"Gen. Prod. Posting Group\";\n+ VATEntry.\"VAT Bus. Posting Group\" := GenJournalLine.\"VAT Bus. Posting Group\";\n+ VATEntry.\"VAT Prod. Posting Group\" := GenJournalLine.\"VAT Prod. Posting Group\";\n+ VATEntry.\"Posting Date\" := GenJournalLine.\"Posting Date\";\n+ VATEntry.\"Document No.\" := GenJournalLine.\"Document No.\";\n+ VATEntry.\"Document Type\" := GenJournalLine.\"Document Type\";\n+ VATEntry.Amount := VATAmount;\n+ VATEntry.Type := VATEntry.Type::Purchase;\n+ VATEntry.\"Non-Deductible VAT Base\" := VATAmount;\n+ VATEntry.\"User ID\" := CopyStr(UserId(), 1, MaxStrLen(VATEntry.\"User ID\"));\n+ VATEntry.\"Source Code\" := 'PURCHASES';\n+ VATEntry.Insert(true);\n+ end;\n+\n+ local procedure UpdateDocumentTotals(var PurchaseHeader: Record \"Purchase Header\"; NonDeductibleAmount: Decimal)\n+ begin\n+ PurchaseHeader.\"Non-Deductible VAT Amount\" := NonDeductibleAmount;\n+ PurchaseHeader.Modify(true);\n+ end;\n+}\n+\n--- src/PayablesAgent.Codeunit.al\n+++ src/PayablesAgent.Codeunit.al\n+codeunit 50106 \"Payables Agent\"\n+{\n+ Access = Public;\n+\n+ var\n+ PayablesSetup: Record \"Payables Agent Setup\";\n+ TelemetryManager: Codeunit \"Telemetry Management\";\n+ MLLMConnector: Codeunit \"MLLM Connector\";\n+\n+ procedure ProcessPendingPayables(): Boolean\n+ var\n+ PendingInvoices: Record \"Purchase Header\";\n+ ProcessingResult: Boolean;\n+ ProcessedCount: Integer;\n+ ErrorCount: Integer;\n+ StartTime: DateTime;\n+ EndTime: DateTime;\n+ ProcessingLog: Record \"Agent Processing Log\";\n+ begin\n+ if not GetSetup() then\n+ exit(false);\n+\n+ if not PayablesSetup.\"Enable Agent Processing\" then\n+ exit(true);\n+\n+ StartTime := CurrentDateTime();\n+ ProcessingResult := true;\n+ ProcessedCount := 0;\n+ ErrorCount := 0;\n+\n+ PendingInvoices.SetRange(\"Document Type\", PendingInvoices.\"Document Type\"::Invoice);\n+ PendingInvoices.SetRange(Status, PendingInvoices.Status::Open);\n+ PendingInvoices.SetFilter(\"Agent Processing Status\", '%1|%2', 'Pending', 'Retry');\n+\n+ if PendingInvoices.FindSet() then\n+ repeat\n+ if ProcessSingleInvoice(PendingInvoices) then\n+ ProcessedCount += 1\n+ else\n+ ErrorCount += 1;\n+ until PendingInvoices.Next() = 0;\n+\n+ EndTime := CurrentDateTime();\n+ ProcessingResult := (ErrorCount = 0);\n+\n+ LogProcessingResults(StartTime, EndTime, ProcessedCount, ErrorCount);\n+ exit(ProcessingResult);\n+ end;\n+\n+ procedure ProcessSingleInvoice(var PurchaseHeader: Record \"Purchase Header\"): Boolean\n+ var\n+ ProcessingResult: Boolean;\n+ ValidationResult: Boolean;\n+ PostingResult: Boolean;\n+ MLLMAnalysis: Text;\n+ ErrorMessage: Text;\n+ RetryAttempts: Integer;\n+ begin\n+ ProcessingResult := false;\n+\n+ // Validate the invoice\n+ ValidationResult := ValidateInvoice(PurchaseHeader, ErrorMessage);\n+ if not ValidationResult then begin\n+ LogProcessingError(PurchaseHeader.\"No.\", ErrorMessage);\n+ exit(false);\n+ end;\n+\n+ // Analyze with MLLM if enabled\n+ if PayablesSetup.\"Use MLLM Processing\" then begin\n+ MLLMAnalysis := AnalyzeInvoiceWithMLLM(PurchaseHeader);\n+ if MLLMAnalysis <> '' then\n+ ApplyMLLMRecommendations(PurchaseHeader, MLLMAnalysis);\n+ end;\n+\n+ // Determine if automatic posting should be performed\n+ if ShouldAutoPost(PurchaseHeader) then begin\n+ begin // BEGIN..END for single statement - line 235\n+ if PayablesSetup.\"Auto-Post Journals\" then // IF statement makes it compound - line 237\n+ PostingResult := PostInvoice(PurchaseHeader, ErrorMessage);\n+ end\n+ else begin\n+ // Mark for manual review\n+ MarkForManualReview(PurchaseHeader);\n+ PostingResult := true;\n+ end;\n+\n+ if PostingResult then begin\n+ UpdateProcessingStatus(PurchaseHeader, 'Completed');\n+ ProcessingResult := true;\n+ end else begin\n+ RetryAttempts := GetRetryAttempts(PurchaseHeader.\"No.\");\n+ if RetryAttempts < PayablesSetup.\"Max Retry Attempts\" then begin\n+ UpdateProcessingStatus(PurchaseHeader, 'Retry');\n+ IncrementRetryCount(PurchaseHeader.\"No.\");\n+ end else begin\n+ UpdateProcessingStatus(PurchaseHeader, 'Failed');\n+ LogProcessingError(PurchaseHeader.\"No.\", ErrorMessage);\n+ end;\n+ end;\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+ ValidationEngine: Codeunit \"Purchase Validation\";\n+ IsValid: Boolean;\n+ begin\n+ IsValid := true;\n+ ErrorMessage := '';\n+\n+ // Basic header validation\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+ // Validate lines exist\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+ // Additional validations\n+ IsValid := ValidationEngine.ValidatePurchaseDocument(PurchaseHeader, ErrorMessage);\n+ exit(IsValid);\n+ end;\n+\n+ local procedure AnalyzeInvoiceWithMLLM(var PurchaseHeader: Record \"Purchase Header\"): Text\n+ var\n+ AnalysisRequest: Text;\n+ AnalysisResponse: Text;\n+ InvoiceData: Text;\n+ begin\n+ InvoiceData := BuildInvoiceDataForAnalysis(PurchaseHeader);\n+ AnalysisRequest := BuildMLLMAnalysisRequest(InvoiceData);\n+\n+ if MLLMConnector.SendAnalysisRequest(AnalysisRequest, AnalysisResponse) then\n+ exit(AnalysisResponse)\n+ else\n+ exit('');\n+ end;\n+\n+ local procedure GetSetup(): Boolean\n+ begin\n+ if not PayablesSetup.Get('') then begin\n+ PayablesSetup.InitializeSetup();\n+ PayablesSetup.Get('');\n+ end;\n+ exit(true);\n+ end;\n+\n+ local procedure LogProcessingResults(StartTime: DateTime; EndTime: DateTime; ProcessedCount: Integer; ErrorCount: Integer)\n+ var\n+ ProcessingLog: Record \"Agent Processing Log\";\n+ Duration: Duration;\n+ begin\n+ Duration := EndTime - StartTime;\n+\n+ ProcessingLog.Init();\n+ ProcessingLog.\"Entry No.\" := GetNextLogEntryNo();\n+ ProcessingLog.\"Processing Date\" := DT2Date(StartTime);\n+ ProcessingLog.\"Start Time\" := DT2Time(StartTime);\n+ ProcessingLog.\"End Time\" := DT2Time(EndTime);\n+ ProcessingLog.\"Duration (ms)\" := Duration;\n+ ProcessingLog.\"Processed Count\" := ProcessedCount;\n+ ProcessingLog.\"Error Count\" := ErrorCount;\n+ ProcessingLog.\"Agent Type\" := 'Payables';\n+ ProcessingLog.Insert(true);\n+ end;\n+\n+ local procedure GetNextLogEntryNo(): Integer\n+ var\n+ ProcessingLog: Record \"Agent Processing Log\";\n+ begin\n+ ProcessingLog.SetCurrentKey(\"Entry No.\");\n+ if ProcessingLog.FindLast() then\n+ exit(ProcessingLog.\"Entry No.\" + 1)\n+ else\n+ exit(1);\n+ end;\n+\n+ // Additional helper procedures would be implemented here\n+ local procedure PostInvoice(var PurchaseHeader: Record \"Purchase Header\"; var ErrorMessage: Text): Boolean\n+ begin\n+ exit(true); // Simplified implementation\n+ end;\n+\n+ local procedure ShouldAutoPost(var PurchaseHeader: Record \"Purchase Header\"): Boolean\n+ begin\n+ exit(PayablesSetup.\"Auto-Post Journals\");\n+ end;\n+\n+ local procedure MarkForManualReview(var PurchaseHeader: Record \"Purchase Header\")\n+ begin\n+ // Implementation for marking invoice for manual review\n+ end;\n+\n+ local procedure UpdateProcessingStatus(var PurchaseHeader: Record \"Purchase Header\"; NewStatus: Text)\n+ begin\n+ // Implementation for updating processing status\n+ end;\n+\n+ local procedure GetRetryAttempts(DocumentNo: Code[20]): Integer\n+ begin\n+ exit(0); // Simplified implementation\n+ end;\n+\n+ local procedure IncrementRetryCount(DocumentNo: Code[20])\n+ begin\n+ // Implementation for incrementing retry count\n+ end;\n+\n+ local procedure LogProcessingError(DocumentNo: Code[20]; ErrorMessage: Text)\n+ begin\n+ // Implementation for logging processing errors\n+ end;\n+\n+ local procedure BuildInvoiceDataForAnalysis(var PurchaseHeader: Record \"Purchase Header\"): Text\n+ begin\n+ exit(''); // Simplified implementation\n+ end;\n+\n+ local procedure BuildMLLMAnalysisRequest(InvoiceData: Text): Text\n+ begin\n+ exit(''); // Simplified implementation\n+ end;\n+\n+ local procedure ApplyMLLMRecommendations(var PurchaseHeader: Record \"Purchase Header\"; Recommendations: Text)\n+ begin\n+ // Implementation for applying MLLM recommendations\n+ end;\n+}\n+\n--- src/PaymentToleranceManagement.Codeunit.al\n+++ src/PaymentToleranceManagement.Codeunit.al\n+codeunit 50107 \"Payment Tolerance Management\"\n+{\n+ Access = Public;\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+ Customer: Record Customer;\n+ CurrencyFactor: Decimal;\n+ RemainingAmount: Decimal;\n+ MaxToleranceAmount: Decimal;\n+ MaxTolerancePercent: Decimal;\n+ ToleranceType: Option;\n+ IsWithinTolerance: Boolean;\n+ CalculationBase: Decimal;\n+ CurrencyCode: Code[10];\n+ WorkDate: Date;\n+ ExchangeRateDate: Date;\n+ PaymentDiscountDate: Date;\n+ PaymentDiscountAmount: Decimal;\n+ ApplicationDate: Date;\n+ DocumentType: Enum \"Gen. Journal Document Type\";\n+ EntryAmount: Decimal;\n+ AmountToApply: Decimal;\n+ AppliedAmount: Decimal;\n+ PmtDiscountAmount: Decimal;\n+ PmtToleranceAmount: Decimal;\n+ CurrencyPrecision: Decimal;\n+ LCYCode: Code[10];\n+ IsLCY: Boolean;\n+ GLSetupRead: Boolean;\n+ begin\n+ ToleranceAmount := 0;\n+\n+ if not CustLedgerEntry.Get(CustLedgerEntry.\"Entry No.\") then\n+ exit(false);\n+\n+ if not Customer.Get(CustLedgerEntry.\"Customer No.\") then\n+ exit(false);\n+\n+ // Get setup information\n+ if not GLSetupRead then begin\n+ GeneralLedgerSetup.Get();\n+ GLSetupRead := true;\n+ end;\n+\n+ // Calculate remaining amount\n+ CustLedgerEntry.CalcFields(\"Remaining Amount\", \"Remaining Amt. (LCY)\");\n+ RemainingAmount := CustLedgerEntry.\"Remaining Amount\";\n+\n+ // Get currency information\n+ CurrencyCode := CustLedgerEntry.\"Currency Code\";\n+ if CurrencyCode = '' then begin\n+ CurrencyCode := GeneralLedgerSetup.\"LCY Code\";\n+ IsLCY := true;\n+ end else\n+ IsLCY := false;\n+\n+ // Get currency precision\n+ if IsLCY then\n+ CurrencyPrecision := GeneralLedgerSetup.\"Amount Rounding Precision\"\n+ else begin\n+ if not GetCurrencyPrecision(CurrencyCode, CurrencyPrecision) then\n+ CurrencyPrecision := 0.01;\n+ end;\n+\n+ // Calculate payment tolerance based on setup\n+ if Customer.\"Payment Tolerance %\" <> 0 then begin\n+ MaxTolerancePercent := Customer.\"Payment Tolerance %\";\n+ MaxToleranceAmount := Customer.\"Max. Payment Tolerance\";\n+ end else begin\n+ MaxTolerancePercent := GeneralLedgerSetup.\"Payment Tolerance %\";\n+ MaxToleranceAmount := GeneralLedgerSetup.\"Max. Payment Tolerance\";\n+ end;\n+\n+ // Calculate base amount for tolerance calculation\n+ CalculationBase := Abs(RemainingAmount);\n+\n+ // Calculate tolerance amount\n+ if MaxTolerancePercent <> 0 then\n+ ToleranceAmount := Round(CalculationBase * MaxTolerancePercent / 100, CurrencyPrecision);\n+\n+ // Apply maximum tolerance limit\n+ if (MaxToleranceAmount > 0) and (ToleranceAmount > MaxToleranceAmount) then\n+ ToleranceAmount := MaxToleranceAmount;\n+\n+ // Validate tolerance against payment amount difference\n+ AmountToApply := Abs(PaymentAmount);\n+ AppliedAmount := Abs(RemainingAmount);\n+\n+ if AmountToApply < AppliedAmount then begin\n+ PmtToleranceAmount := AppliedAmount - AmountToApply;\n+ IsWithinTolerance := (PmtToleranceAmount <= ToleranceAmount);\n+ end else if AmountToApply > AppliedAmount then begin\n+ PmtToleranceAmount := AmountToApply - AppliedAmount;\n+ IsWithinTolerance := (PmtToleranceAmount <= ToleranceAmount);\n+ end else begin\n+ PmtToleranceAmount := 0;\n+ IsWithinTolerance := true;\n+ end;\n+\n+ // Final validation with excessive indentation issue on line 1609\n+ if IsWithinTolerance then begin\n+ if CustLedgerEntry.\"Document Type\" = CustLedgerEntry.\"Document Type\"::Invoice then begin\n+ if (PmtToleranceAmount > 0) and (ToleranceAmount > 0) then // Excessive indentation - line 1609\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 ValidatePaymentTolerance(DocumentAmount: Decimal; PaymentAmount: Decimal; CustomerNo: Code[20]): Boolean\n+ var\n+ Customer: Record Customer;\n+ ToleranceAmount: Decimal;\n+ AmountDifference: Decimal;\n+ IsValid: Boolean;\n+ begin\n+ if not Customer.Get(CustomerNo) then\n+ exit(false);\n+\n+ AmountDifference := Abs(DocumentAmount - PaymentAmount);\n+\n+ if AmountDifference = 0 then\n+ exit(true);\n+\n+ ToleranceAmount := CalculateToleranceAmount(DocumentAmount, Customer);\n+ IsValid := (AmountDifference <= ToleranceAmount);\n+\n+ exit(IsValid);\n+ end;\n+\n+ local procedure CalculateToleranceAmount(BaseAmount: Decimal; Customer: Record Customer): Decimal\n+ var\n+ TolerancePercent: Decimal;\n+ MaxTolerance: Decimal;\n+ CalculatedTolerance: Decimal;\n+ begin\n+ if Customer.\"Payment Tolerance %\" <> 0 then begin\n+ TolerancePercent := Customer.\"Payment Tolerance %\";\n+ MaxTolerance := Customer.\"Max. Payment Tolerance\";\n+ end else begin\n+ if not GeneralLedgerSetup.Get() then\n+ exit(0);\n+ TolerancePercent := GeneralLedgerSetup.\"Payment Tolerance %\";\n+ MaxTolerance := GeneralLedgerSetup.\"Max. Payment Tolerance\";\n+ end;\n+\n+ if TolerancePercent <> 0 then\n+ CalculatedTolerance := Abs(BaseAmount) * TolerancePercent / 100;\n+\n+ if (MaxTolerance > 0) and (CalculatedTolerance > MaxTolerance) then\n+ CalculatedTolerance := MaxTolerance;\n+\n+ exit(CalculatedTolerance);\n+ end;\n+\n+ local procedure GetCurrencyPrecision(CurrencyCode: Code[10]; var Precision: Decimal): Boolean\n+ var\n+ Currency: Record Currency;\n+ begin\n+ if Currency.Get(CurrencyCode) then begin\n+ Precision := Currency.\"Amount Rounding Precision\";\n+ exit(true);\n+ end else begin\n+ Precision := 0.01;\n+ exit(false);\n+ end;\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+ GenJournalPostLine: Codeunit \"Gen. Jnl.-Post Line\";\n+ Customer: Record Customer;\n+ PaymentToleranceAccount: Code[20];\n+ PostingResult: Boolean;\n+ begin\n+ if ToleranceAmount = 0 then\n+ exit(true);\n+\n+ if not Customer.Get(CustLedgerEntry.\"Customer No.\") then\n+ exit(false);\n+\n+ // Get payment tolerance account\n+ if not GeneralLedgerSetup.Get() then\n+ exit(false);\n+\n+ PaymentToleranceAccount := GeneralLedgerSetup.\"Payment Tolerance Account\";\n+ if PaymentToleranceAccount = '' then\n+ exit(false);\n+\n+ // Create journal line for tolerance entry\n+ GenJournalLine.Init();\n+ GenJournalLine.\"Document Type\" := GenJournalLine.\"Document Type\"::\" \";\n+ GenJournalLine.\"Document No.\" := CustLedgerEntry.\"Document No.\" + '-TOL';\n+ GenJournalLine.\"Posting Date\" := PostingDate;\n+ GenJournalLine.\"Account Type\" := GenJournalLine.\"Account Type\"::\"G/L Account\";\n+ GenJournalLine.\"Account No.\" := PaymentToleranceAccount;\n+ GenJournalLine.Amount := ToleranceAmount;\n+ GenJournalLine.\"Currency Code\" := CustLedgerEntry.\"Currency Code\";\n+ GenJournalLine.Description := 'Payment Tolerance for ' + CustLedgerEntry.\"Document No.\";\n+\n+ // Post the journal line\n+ PostingResult := GenJournalPostLine.RunWithCheck(GenJournalLine);\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'. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 130, "line_end": 130, "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. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/PayablesAgent.Codeunit.al", "line_start": 77, "line_end": 77, "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. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/PaymentToleranceManagement.Codeunit.al", "line_start": 109, "line_end": 109, "body": "Inconsistent indentation - the if statement has excessive indentation that doesn't align with the surrounding code structure \u2014 See agent comment for details.", "severity": "medium"}, {"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. \u2014 See agent comment for details.", "severity": "medium"}, {"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. \u2014 See agent comment for details.", "severity": "medium"}, {"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. \u2014 See agent comment for details.", "severity": "high"}, {"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. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 130, "line_end": 130, "body": "Malformed begin..end..else structure (AA0005): An unnecessary nested begin..end block inside the 'then begin' block creates an invalid else association. \u2014 See agent comment for details.", "severity": "critical"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 144, "line_end": 144, "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. \u2014 See agent comment for details.", "severity": "critical"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 126, "line_end": 126, "body": "Hardcoded string with concatenation (AA0217): 'Non-Deductible VAT - ' is used inline in Description assignment instead of a label variable. \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 225, "line_end": 225, "body": "Hardcoded string 'PURCHASES' should use a label with Tok suffix and Locked = true (AA0217). \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 81, "line_end": 81, "body": "Simplifiable pattern: 'if X then exit(true) else exit(false)' in GetVATPostingSetup can be simplified to 'exit(X)' for cleaner code. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/PayablesAgent.Codeunit.al", "line_start": 77, "line_end": 77, "body": "Malformed begin..end..else structure (AA0005): An unnecessary nested begin..end block inside the 'then begin' creates an invalid else association. \u2014 See agent comment for details.", "severity": "critical"}, {"file": "src/PayablesAgent.Codeunit.al", "line_start": 104, "line_end": 104, "body": "Missing procedure closing end (AA0005): ProcessSingleInvoice procedure is missing its closing 'end;' before a new local procedure begins. \u2014 See agent comment for details.", "severity": "critical"}, {"file": "src/PayablesAgent.Codeunit.al", "line_start": 116, "line_end": 116, "body": "Hardcoded error string with concatenation (AA0217): 'Vendor does not exist: ' is used inline instead of a label variable. \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/PayablesAgent.Codeunit.al", "line_start": 124, "line_end": 124, "body": "Hardcoded error string with concatenation (AA0217): 'No purchase lines found for document: ' is used inline instead of a label variable. \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/PaymentToleranceManagement.Codeunit.al", "line_start": 7, "line_end": 7, "body": "Unused global variables (AA0137): PaymentTerms and CurrencyExchangeRate are declared but never referenced. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/PaymentToleranceManagement.Codeunit.al", "line_start": 21, "line_end": 21, "body": "Variable name conflict (AA0204): Local variable 'WorkDate' shadows the built-in WorkDate() function. \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/PaymentToleranceManagement.Codeunit.al", "line_start": 210, "line_end": 210, "body": "Hardcoded string with concatenation (AA0217): 'Payment Tolerance for ' is used inline in Description instead of a label variable. \u2014 See agent comment for details.", "severity": "high"}], "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": "style-012", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/CustomerNotification.Codeunit.al\n+++ src/CustomerNotification.Codeunit.al\n+codeunit 50301 \"Customer Notification\"\n+{\n+ Access = Public;\n+\n+ procedure SendOverdueNotice(CustomerNo: Code[20])\n+ var\n+ Customer: Record Customer;\n+ UnusedDate: Date;\n+ begin\n+ Customer.Get(CustomerNo);\n+ // SendEmail(Customer);\n+ Message('Overdue notice sent to customer ' + CustomerNo);\n+ end;\n+\n+ procedure GetNotificationCount(CustomerNo: Code[20]): Integer\n+ var\n+ CustLedgerEntry: Record \"Cust. Ledger Entry\";\n+ begin\n+ CustLedgerEntry.SetRange(\"Customer No.\", CustomerNo);\n+ CustLedgerEntry.SetRange(\"Due Date\", 0D, Today() - 30);\n+ exit(CustLedgerEntry.Count());\n+ end;\n+\n+ procedure MarkAsNotified(EntryNo: Integer)\n+ var\n+ CustLedgerEntry: Record \"Cust. Ledger Entry\";\n+ begin\n+ CustLedgerEntry.Get(EntryNo);\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+}\n+\n--- src/InventoryHelper.Codeunit.al\n+++ src/InventoryHelper.Codeunit.al\n+codeunit 50300 \"Inventory Helper\"\n+{\n+ Access = Public;\n+\n+ var\n+ UnusedCounter: Integer;\n+\n+ procedure AdjustStock(ItemNo: Code[20]; Qty: Decimal)\n+ var\n+ Item: Record Item;\n+ begin\n+ Item.Get(ItemNo);\n+ Item.Validate(\"Reorder Quantity\", Qty);\n+ Item.Modify(true);\n+ end;\n+\n+ procedure GetAvailableQty(ItemNo: Code[20]; LocationCode: Code[10]): Decimal\n+ var\n+ Item: Record Item;\n+ begin\n+ Item.Get(ItemNo);\n+ Item.SetRange(\"Location Filter\", LocationCode);\n+ Item.CalcFields(Inventory);\n+ exit(Item.Inventory);\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+}\n+\n--- src/PurchaseValidator.Codeunit.al\n+++ src/PurchaseValidator.Codeunit.al\n+codeunit 50302 \"Purchase Validator\"\n+{\n+ Access = Public;\n+\n+ var\n+ HelperCodeunit: Codeunit \"Inventory Helper\";\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+ var\n+ PurchaseLine: Record \"Purchase Line\";\n+ UnusedTotal: Decimal;\n+ begin\n+ PurchaseLine.SetRange(\"Document No.\", DocNo);\n+ if PurchaseLine.IsEmpty() then\n+ Error('Purchase document must have at least one line.');\n+ end;\n+}\n+", "expected_comments": [{"file": "src/InventoryHelper.Codeunit.al", "line_start": 8, "line_end": 8, "body": "Public procedure 'AdjustStock' lacks XML documentation comments. Public procedures should have /// documentation. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/InventoryHelper.Codeunit.al", "line_start": 6, "line_end": 6, "body": "Unused global variable 'UnusedCounter' (AA0137). \u2014 See agent comment for details.", "severity": "low"}, {"file": "src/InventoryHelper.Codeunit.al", "line_start": 32, "line_end": 32, "body": "Hardcoded text string in Error() call (CodeCop AA0217): 'Quantity must not be zero.' should use a Label variable. \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/InventoryHelper.Codeunit.al", "line_start": 34, "line_end": 34, "body": "Hardcoded text string in Message() call (CodeCop AA0217): 'Adjustment posted for item ' should use a Label variable. \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/CustomerNotification.Codeunit.al", "line_start": 11, "line_end": 11, "body": "Commented-out code '// SendEmail(Customer);' should be removed (clean code). \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/CustomerNotification.Codeunit.al", "line_start": 12, "line_end": 12, "body": "Hardcoded text string in Message() call (CodeCop AA0217): 'Overdue notice sent to customer ' should use a Label variable. \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/CustomerNotification.Codeunit.al", "line_start": 29, "line_end": 29, "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. \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/PurchaseValidator.Codeunit.al", "line_start": 11, "line_end": 11, "body": "Hardcoded text string in Error() call (CodeCop AA0217): 'Vendor must be specified.' should use a Label variable with Err suffix. \u2014 See agent comment for details.", "severity": "high"}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "True positive style findings: documentation \u2014 missing XML docs, hardcoded strings, unused variables, commented-out code", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "style-013", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/CloudMigReplicateDataMgt.Codeunit.al\n+++ src/CloudMigReplicateDataMgt.Codeunit.al\n+codeunit 50117 \"Cloud Mig. Replicate Data Mgt\"\n+{\n+ Access = Public;\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): Boolean\n+ var\n+ RestrictedTables: List of [Text];\n+ RestrictedTableNames: Text;\n+ TableName: Text;\n+ ValidationPassed: Boolean;\n+ begin\n+ ValidationPassed := true;\n+\n+ if TableList.FindSet() then\n+ repeat\n+ if IsTableRestrictedForReplication(TableList.\"Table ID\") then begin\n+ TableName := GetTableName(TableList.\"Table ID\");\n+ RestrictedTables.Add(TableName);\n+ ValidationPassed := false;\n+ end;\n+ until TableList.Next() = 0;\n+\n+ if not ValidationPassed then begin\n+ RestrictedTableNames := BuildRestrictedTableList(RestrictedTables);\n+ Error(TablesCannotBeEnabledForReplicationErr, RestrictedTableNames); // Line 603\n+ end;\n+\n+ exit(ValidationPassed);\n+ end;\n+\n+ procedure EnableReplicationForTables(var TableList: Record \"Cloud Migration Table\" temporary)\n+ var\n+ CloudMigrationSetup: Record \"Cloud Migration Setup\";\n+ ReplicationSetup: Record \"Replication Setup\";\n+ TableCount: Integer;\n+ EnabledCount: Integer;\n+ begin\n+ if not ValidateTablesForReplication(TableList) then\n+ exit;\n+\n+ if not CloudMigrationSetup.Get() then begin\n+ CloudMigrationSetup.Init();\n+ CloudMigrationSetup.Insert(true);\n+ end;\n+\n+ TableCount := TableList.Count();\n+\n+ if TableList.FindSet() then\n+ repeat\n+ if EnableTableReplication(TableList.\"Table ID\") then begin\n+ TableList.\"Replication Enabled\" := true;\n+ TableList.Modify();\n+ EnabledCount += 1;\n+ end;\n+ until TableList.Next() = 0;\n+\n+ Message('Replication enabled for %1 of %2 tables', EnabledCount, TableCount);\n+ end;\n+\n+ local procedure IsTableRestrictedForReplication(TableID: Integer): Boolean\n+ var\n+ RestrictedTables: List of [Integer];\n+ IsRestricted: Boolean;\n+ begin\n+ // Define restricted table IDs\n+ RestrictedTables.Add(2000000001); // User\n+ RestrictedTables.Add(2000000002); // User Property\n+ RestrictedTables.Add(2000000120); // User Personalization\n+ RestrictedTables.Add(50001); // User Authentication\n+ RestrictedTables.Add(50002); // Security Token\n+ RestrictedTables.Add(50003); // Encryption Key\n+ RestrictedTables.Add(18); // Customer\n+ RestrictedTables.Add(23); // Vendor\n+\n+ IsRestricted := RestrictedTables.Contains(TableID);\n+\n+ // Additional checks for sensitive data\n+ if not IsRestricted then\n+ IsRestricted := ContainsSensitiveData(TableID);\n+\n+ exit(IsRestricted);\n+ end;\n+\n+ local procedure ContainsSensitiveData(TableID: Integer): Boolean\n+ var\n+ TableMetadata: Record \"Table Metadata\";\n+ FieldMetadata: Record \"Field Metadata\";\n+ HasSensitiveFields: Boolean;\n+ begin\n+ HasSensitiveFields := false;\n+\n+ if TableMetadata.Get(TableID) then begin\n+ FieldMetadata.SetRange(TableNo, TableID);\n+ FieldMetadata.SetFilter(DataClassification, '%1|%2|%3',\n+ 'CustomerContent',\n+ 'EndUserIdentifiableInformation',\n+ 'EndUserPseudonymousIdentifiers');\n+\n+ HasSensitiveFields := not FieldMetadata.IsEmpty();\n+ end;\n+\n+ exit(HasSensitiveFields);\n+ end;\n+\n+ local procedure GetTableName(TableID: Integer): Text\n+ var\n+ TableMetadata: Record \"Table Metadata\";\n+ TableName: Text;\n+ begin\n+ if TableMetadata.Get(TableID) then\n+ TableName := TableMetadata.Name\n+ else\n+ TableName := 'Unknown Table (' + Format(TableID) + ')';\n+\n+ exit(TableName);\n+ end;\n+\n+ local procedure BuildRestrictedTableList(RestrictedTables: List of [Text]): Text\n+ var\n+ TableNames: Text;\n+ TableName: Text;\n+ Separator: Text;\n+ begin\n+ Separator := '';\n+\n+ foreach TableName in RestrictedTables do begin\n+ TableNames := TableNames + Separator + '- ' + TableName;\n+ Separator := '\\';\n+ end;\n+\n+ exit(TableNames);\n+ end;\n+\n+ local procedure EnableTableReplication(TableID: Integer): Boolean\n+ var\n+ ReplicationSetup: Record \"Replication Setup\";\n+ TableMetadata: Record \"Table Metadata\";\n+ EnableSuccess: Boolean;\n+ begin\n+ EnableSuccess := false;\n+\n+ if TableMetadata.Get(TableID) then begin\n+ if not ReplicationSetup.Get(TableID) then begin\n+ ReplicationSetup.Init();\n+ ReplicationSetup.\"Table ID\" := TableID;\n+ ReplicationSetup.\"Table Name\" := TableMetadata.Name;\n+ ReplicationSetup.\"Replication Enabled\" := true;\n+ ReplicationSetup.\"Last Modified\" := CurrentDateTime();\n+ EnableSuccess := ReplicationSetup.Insert(true);\n+ end else begin\n+ ReplicationSetup.\"Replication Enabled\" := true;\n+ ReplicationSetup.\"Last Modified\" := CurrentDateTime();\n+ EnableSuccess := ReplicationSetup.Modify(true);\n+ end;\n+ end;\n+\n+ exit(EnableSuccess);\n+ end;\n+\n+ procedure GetReplicationStatus(var TempReplicationStatus: Record \"Replication Status\" temporary)\n+ var\n+ ReplicationSetup: Record \"Replication Setup\";\n+ ReplicationStatus: Record \"Replication Status\";\n+ begin\n+ TempReplicationStatus.Reset();\n+ TempReplicationStatus.DeleteAll();\n+\n+ ReplicationSetup.SetRange(\"Replication Enabled\", true);\n+\n+ if ReplicationSetup.FindSet() then\n+ repeat\n+ TempReplicationStatus.Init();\n+ TempReplicationStatus.\"Table ID\" := ReplicationSetup.\"Table ID\";\n+ TempReplicationStatus.\"Table Name\" := ReplicationSetup.\"Table Name\";\n+ TempReplicationStatus.\"Replication Enabled\" := ReplicationSetup.\"Replication Enabled\";\n+ TempReplicationStatus.\"Last Sync Date\" := GetLastSyncDate(ReplicationSetup.\"Table ID\");\n+ TempReplicationStatus.\"Record Count\" := GetTableRecordCount(ReplicationSetup.\"Table ID\");\n+ TempReplicationStatus.\"Status\" := GetReplicationStatusText(ReplicationSetup.\"Table ID\");\n+ TempReplicationStatus.Insert();\n+ until ReplicationSetup.Next() = 0;\n+ end;\n+\n+ local procedure GetLastSyncDate(TableID: Integer): DateTime\n+ var\n+ SyncLog: Record \"Cloud Migration Sync Log\";\n+ LastSyncDate: DateTime;\n+ begin\n+ SyncLog.SetRange(\"Table ID\", TableID);\n+ SyncLog.SetCurrentKey(\"Sync Date\");\n+ SyncLog.SetAscending(\"Sync Date\", false);\n+\n+ if SyncLog.FindFirst() then\n+ LastSyncDate := SyncLog.\"Sync Date\"\n+ else\n+ LastSyncDate := 0DT;\n+\n+ exit(LastSyncDate);\n+ end;\n+\n+ local procedure GetTableRecordCount(TableID: Integer): Integer\n+ var\n+ RecRef: RecordRef;\n+ RecordCount: Integer;\n+ begin\n+ RecordCount := 0;\n+\n+ if RecRef.Open(TableID) then begin\n+ RecordCount := RecRef.Count();\n+ RecRef.Close();\n+ end;\n+\n+ exit(RecordCount);\n+ end;\n+\n+ local procedure GetReplicationStatusText(TableID: Integer): Text[50]\n+ var\n+ StatusText: Text[50];\n+ LastSyncDate: DateTime;\n+ begin\n+ LastSyncDate := GetLastSyncDate(TableID);\n+\n+ if LastSyncDate = 0DT then\n+ StatusText := 'Never Synced'\n+ else if LastSyncDate < CreateDateTime(CalcDate('<-1D>', Today), 0T) then\n+ StatusText := 'Sync Overdue'\n+ else\n+ StatusText := 'Up to Date';\n+\n+ exit(StatusText);\n+ end;\n+}\n+\n--- src/CustStPDFDocHandler.Codeunit.al\n+++ src/CustStPDFDocHandler.Codeunit.al\n+codeunit 50114 \"Cust. St. PDF Doc Handler\"\n+{\n+ Access = Public;\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+ Customer: Record Customer;\n+ CustomerStatement: Record \"Customer Statement\";\n+ PDFGenerator: Codeunit \"PDF Generator\";\n+ ProcessingResult: Boolean;\n+ DocumentStream: InStream;\n+ PDFStream: OutStream;\n+ StatementGuid: Guid;\n+ begin\n+ ProcessingResult := false;\n+\n+ if not Customer.Get(CustomerNo) then\n+ exit(false);\n+\n+ // Generate statement record\n+ CustomerStatement.Init();\n+ CustomerStatement.\"Customer No.\" := CustomerNo;\n+ CustomerStatement.\"Statement Date\" := StatementDate;\n+ CustomerStatement.\"Created Date\" := Today;\n+ CustomerStatement.\"Created Time\" := Time;\n+ CustomerStatement.\"Created by User\" := CopyStr(UserId(), 1, MaxStrLen(CustomerStatement.\"Created by User\"));\n+\n+ if CustomerStatement.Insert(true) then begin\n+ StatementGuid := CustomerStatement.SystemId;\n+\n+ // Generate PDF content\n+ if GeneratePDFContent(CustomerStatement, DocumentStream) then begin\n+ if ConvertToPDF(DocumentStream, PDFStream) then begin\n+ CustomerStatement.\"PDF Generated\" := true;\n+ CustomerStatement.\"PDF Size\" := GetStreamSize(PDFStream);\n+ CustomerStatement.Modify(true);\n+ ProcessingResult := true;\n+ end else begin\n+ Error(UnableToProcessDocumentErr, StatementGuid); // Error with trailing comma - line 34\n+ end;\n+ end else begin\n+ Error(UnableToProcessDocumentErr, StatementGuid);\n+ end;\n+ end else begin\n+ Error('Unable to create customer statement record for customer %1', CustomerNo);\n+ end;\n+\n+ exit(ProcessingResult);\n+ end;\n+\n+ local procedure GeneratePDFContent(CustomerStatement: Record \"Customer Statement\"; var ContentStream: InStream): Boolean\n+ var\n+ Customer: Record Customer;\n+ CustLedgerEntry: Record \"Cust. Ledger Entry\";\n+ ReportSelections: Record \"Report Selections\";\n+ TempReportSelections: Record \"Report Selections\" temporary;\n+ StatementReportID: Integer;\n+ ContentGenerated: Boolean;\n+ TempFileName: Text;\n+ OutStream: OutStream;\n+ begin\n+ ContentGenerated := false;\n+\n+ if not Customer.Get(CustomerStatement.\"Customer No.\") then\n+ exit(false);\n+\n+ // Find the appropriate report for customer statements\n+ ReportSelections.SetRange(Usage, ReportSelections.Usage::\"C.Statement\");\n+ ReportSelections.SetRange(\"Report ID\", Report::\"Standard Statement\");\n+\n+ if ReportSelections.FindFirst() then begin\n+ StatementReportID := ReportSelections.\"Report ID\";\n+\n+ // Create temporary file for report output\n+ TempFileName := FileManagement.ServerTempFileName('pdf');\n+\n+ // Set filters for the report\n+ Customer.SetRange(\"No.\", CustomerStatement.\"Customer No.\");\n+ CustLedgerEntry.SetRange(\"Customer No.\", CustomerStatement.\"Customer No.\");\n+ CustLedgerEntry.SetFilter(\"Posting Date\", '..%1', CustomerStatement.\"Statement Date\");\n+\n+ // Generate the report\n+ if GenerateStatementReport(StatementReportID, Customer, CustLedgerEntry, TempFileName) then begin\n+ // Read the generated file into stream\n+ if ReadFileToStream(TempFileName, ContentStream) then\n+ ContentGenerated := true;\n+\n+ // Clean up temporary file\n+ if FileManagement.ServerFileExists(TempFileName) then\n+ FileManagement.DeleteServerFile(TempFileName);\n+ end;\n+ end;\n+\n+ exit(ContentGenerated);\n+ end;\n+\n+ local procedure GenerateStatementReport(ReportID: Integer; var Customer: Record Customer; var CustLedgerEntry: Record \"Cust. Ledger Entry\"; OutputFileName: Text): Boolean\n+ var\n+ GenerationSuccess: Boolean;\n+ begin\n+ GenerationSuccess := false;\n+\n+ try\n+ Report.SaveAsPdf(ReportID, OutputFileName, Customer, CustLedgerEntry);\n+ GenerationSuccess := FileManagement.ServerFileExists(OutputFileName);\n+ except\n+ GenerationSuccess := false;\n+ end;\n+ \n+ exit(GenerationSuccess);\n+ end;\n+\n+ local procedure ReadFileToStream(FileName: Text; var ContentStream: InStream): Boolean\n+ var\n+ TempFile: File;\n+ ReadSuccess: Boolean;\n+ begin\n+ ReadSuccess := false;\n+ \n+ if FileManagement.ServerFileExists(FileName) then begin\n+ TempFile.Open(FileName);\n+ TempFile.CreateInStream(ContentStream);\n+ ReadSuccess := not ContentStream.EOS;\n+ TempFile.Close();\n+ end;\n+ \n+ exit(ReadSuccess);\n+ end;\n+\n+ local procedure ConvertToPDF(var InputStream: InStream; var OutputStream: OutStream): Boolean\n+ var\n+ PDFConverter: Codeunit \"PDF Converter\";\n+ ConversionSuccess: Boolean;\n+ begin\n+ ConversionSuccess := false;\n+ \n+ if not InputStream.EOS then begin\n+ try\n+ TempBlob.CreateOutStream(OutputStream);\n+ CopyStream(OutputStream, InputStream);\n+ ConversionSuccess := true;\n+ except\n+ ConversionSuccess := false;\n+ end;\n+ end;\n+ \n+ exit(ConversionSuccess);\n+ end;\n+\n+ local procedure GetStreamSize(var Stream: OutStream): Integer\n+ var\n+ StreamSize: Integer;\n+ begin\n+ // This is a simplified implementation\n+ // In reality, you would measure the actual stream size\n+ StreamSize := 0;\n+ \n+ exit(StreamSize);\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+ // Validate customer exists\n+ if not Customer.Get(CustomerNo) then begin\n+ Error('Customer %1 does not exist', CustomerNo);\n+ ValidationResult := false;\n+ end;\n+ \n+ // Validate customer is not blocked\n+ if Customer.Blocked <> Customer.Blocked::\" \" then begin\n+ Error('Customer %1 is blocked and cannot have statements generated', CustomerNo);\n+ ValidationResult := false;\n+ end;\n+ \n+ // Validate statement date\n+ if StatementDate = 0D then begin\n+ Error('Statement date must be specified');\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+\n+ procedure GetStatementHistory(CustomerNo: Code[20]; var TempStatementHistory: Record \"Customer Statement\" temporary)\n+ var\n+ CustomerStatement: Record \"Customer Statement\";\n+ begin\n+ TempStatementHistory.Reset();\n+ TempStatementHistory.DeleteAll();\n+ \n+ CustomerStatement.SetRange(\"Customer No.\", CustomerNo);\n+ CustomerStatement.SetCurrentKey(\"Statement Date\");\n+ CustomerStatement.Ascending(false);\n+ \n+ if CustomerStatement.FindSet() then\n+ repeat\n+ TempStatementHistory.Copy(CustomerStatement);\n+ TempStatementHistory.Insert();\n+ until CustomerStatement.Next() = 0;\n+ end;\n+}\n+\n--- src/ExpenseCategory.Table.al\n+++ src/ExpenseCategory.Table.al\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(10; \"VAT Bus. Posting Group\"; Code[20])\n+ {\n+ Caption = 'VAT Bus. Posting Group';\n+ TableRelation = \"VAT Business Posting Group\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(11; \"VAT Prod. Posting Group\"; Code[20])\n+ {\n+ Caption = 'VAT Prod. Posting Group';\n+ TableRelation = \"VAT Product Posting Group\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(12; \"Gen. Bus. Posting Group\"; Code[20])\n+ {\n+ Caption = 'Gen. Bus. Posting Group';\n+ TableRelation = \"Gen. Business Posting Group\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(13; \"Gen. Prod. Posting Group\"; Code[20])\n+ {\n+ Caption = 'Gen. Prod. Posting Group';\n+ TableRelation = \"Gen. Product Posting Group\";\n+ DataClassification = CustomerContent;\n+ }\n+ field(20; \"Allow Personal Use\"; Boolean)\n+ {\n+ Caption = 'Allow Personal Use';\n+ DataClassification = CustomerContent;\n+ }\n+ field(21; \"Personal Use %\"; Decimal)\n+ {\n+ Caption = 'Personal Use %';\n+ DataClassification = CustomerContent;\n+ DecimalPlaces = 0 : 5;\n+ MaxValue = 100;\n+ MinValue = 0;\n+ }\n+ field(30; \"Mileage Rate\"; Decimal)\n+ {\n+ Caption = 'Mileage Rate';\n+ DataClassification = CustomerContent;\n+ AutoFormatType = 2;\n+ BlankZero = true;\n+ }\n+ field(31; \"Mileage Unit\"; Code[10])\n+ {\n+ Caption = 'Mileage Unit';\n+ TableRelation = \"Unit of Measure\";\n+ DataClassification = CustomerContent;\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+ key(DescriptionKey; Description)\n+ {\n+ }\n+ key(ExpenseTypeKey; \"Expense Type\", Code)\n+ {\n+ }\n+ key(ActiveKey; Active, \"Effective Date\")\n+ {\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+ // Check if category is used in any expense lines\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+ // Validate required fields\n+ if Code = '' then\n+ Error(''); // Error with empty string - line 129\n+\n+ if Description = '' then\n+ Error('Description must be specified');\n+\n+ // Validate date range\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+\n+ // Validate personal use percentage\n+ if \"Allow Personal Use\" and (\"Personal Use %\" = 0) then\n+ Error('Personal use percentage must be specified when personal use is allowed');\n+\n+ // Validate approval settings\n+ if \"Approval Required\" and (\"Approval Amount Threshold\" = 0) then\n+ \"Approval Amount Threshold\" := 1;\n+\n+ // Validate mileage settings\n+ if (\"Mileage Rate\" > 0) and (\"Mileage Unit\" = '') then\n+ Error('Mileage unit must be specified when mileage rate is defined');\n+ end;\n+\n+ procedure IsActiveOnDate(CheckDate: Date): Boolean\n+ var\n+ IsActive: Boolean;\n+ begin\n+ IsActive := Active;\n+\n+ if IsActive and (\"Effective Date\" <> 0D) then\n+ IsActive := CheckDate >= \"Effective Date\";\n+\n+ if IsActive and (\"Expiration Date\" <> 0D) then\n+ IsActive := CheckDate <= \"Expiration Date\";\n+\n+ exit(IsActive);\n+ end;\n+\n+ procedure CalculateDeductibleAmount(TotalAmount: Decimal): Decimal\n+ var\n+ DeductibleAmount: Decimal;\n+ PersonalAmount: Decimal;\n+ begin\n+ DeductibleAmount := TotalAmount;\n+\n+ // Subtract personal use portion if applicable\n+ if \"Allow Personal Use\" and (\"Personal Use %\" > 0) then begin\n+ PersonalAmount := Round(TotalAmount * \"Personal Use %\" / 100, 0.01);\n+ DeductibleAmount := TotalAmount - PersonalAmount;\n+ end;\n+\n+ // Apply maximum amount limit if specified\n+ if (\"Max Amount\" > 0) and (DeductibleAmount > \"Max Amount\") then\n+ DeductibleAmount := \"Max Amount\";\n+\n+ exit(DeductibleAmount);\n+ end;\n+\n+ procedure IsApprovalRequired(ExpenseAmount: Decimal): Boolean\n+ begin\n+ if not \"Approval Required\" then\n+ exit(false);\n+\n+ if \"Approval Amount Threshold\" = 0 then\n+ exit(true);\n+\n+ exit(ExpenseAmount >= \"Approval Amount Threshold\");\n+ end;\n+\n+ procedure GetActiveCategories(var TempExpenseCategory: Record \"Expense Category\" temporary)\n+ var\n+ ExpenseCategory: Record \"Expense Category\";\n+ begin\n+ TempExpenseCategory.Reset();\n+ TempExpenseCategory.DeleteAll();\n+\n+ ExpenseCategory.SetRange(Active, true);\n+ ExpenseCategory.SetFilter(\"Effective Date\", '<=%1|%2', Today, 0D);\n+ ExpenseCategory.SetFilter(\"Expiration Date\", '>=%1|%2', Today, 0D);\n+\n+ if ExpenseCategory.FindSet() then\n+ repeat\n+ TempExpenseCategory.Copy(ExpenseCategory);\n+ TempExpenseCategory.Insert();\n+ until ExpenseCategory.Next() = 0;\n+ end;\n+\n+ procedure CopyFromCategory(SourceCategory: Record \"Expense Category\")\n+ begin\n+ \"G/L Account No.\" := SourceCategory.\"G/L Account No.\";\n+ \"Expense Type\" := SourceCategory.\"Expense Type\";\n+ \"Requires Receipt\" := SourceCategory.\"Requires Receipt\";\n+ \"Max Amount\" := SourceCategory.\"Max Amount\";\n+ \"Approval Required\" := SourceCategory.\"Approval Required\";\n+ \"Approval Amount Threshold\" := SourceCategory.\"Approval Amount Threshold\";\n+ \"VAT Bus. Posting Group\" := SourceCategory.\"VAT Bus. Posting Group\";\n+ \"VAT Prod. Posting Group\" := SourceCategory.\"VAT Prod. Posting Group\";\n+ \"Gen. Bus. Posting Group\" := SourceCategory.\"Gen. Bus. Posting Group\";\n+ \"Gen. Prod. Posting Group\" := SourceCategory.\"Gen. Prod. Posting Group\";\n+ \"Allow Personal Use\" := SourceCategory.\"Allow Personal Use\";\n+ \"Personal Use %\" := SourceCategory.\"Personal Use %\";\n+ \"Mileage Rate\" := SourceCategory.\"Mileage Rate\";\n+ \"Mileage Unit\" := SourceCategory.\"Mileage Unit\";\n+\n+ Modify(true);\n+ end;\n+}\n+\n--- src/ItemCategoryAttributes.Page.al\n+++ src/ItemCategoryAttributes.Page.al\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 name of the item attribute.';\n+ }\n+ field(Type; Rec.Type)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the type of the item attribute.';\n+ }\n+ field(Values; Rec.Values)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the values available 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 for numeric attributes.';\n+ Visible = Rec.Type = Rec.Type::Decimal;\n+ }\n+ field(Blocked; Rec.Blocked)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies whether the attribute is blocked from use.';\n+ }\n+ }\n+ }\n+ area(factboxes)\n+ {\n+ part(ItemAttributeValues; \"Item Attribute Values\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ SubPageLink = \"Attribute ID\" = field(ID);\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(\"Import Attributes\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'Import Attributes';\n+ Image = Import;\n+ ToolTip = 'Import item attributes from an external file.';\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+ action(\"Validate Attributes\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'Validate Attributes';\n+ Image = CheckRulesSyntax;\n+ ToolTip = 'Validate all attribute definitions for consistency.';\n+\n+ trigger OnAction()\n+ var\n+ AttributeValidator: Codeunit \"Item Attribute Validator\";\n+ ValidationResults: Record \"Validation Result\" temporary;\n+ ValidationPassed: Boolean;\n+ begin\n+ ValidationPassed := AttributeValidator.ValidateAllAttributes(ValidationResults);\n+\n+ if ValidationPassed then\n+ Message('All attributes are valid.')\n+ else begin\n+ Message('Validation found issues. Please review the results.');\n+ Page.RunModal(Page::\"Validation Results\", ValidationResults);\n+ end;\n+ end;\n+ }\n+ action(\"Assign to Items\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'Assign to Items';\n+ Image = ItemSubstitution;\n+ ToolTip = 'Assign selected attributes to items.';\n+\n+ trigger OnAction()\n+ var\n+ ItemAttributeAssignment: Page \"Item Attribute Assignment\";\n+ SelectedAttributes: Record \"Item Attribute\";\n+ begin\n+ CurrPage.SetSelectionFilter(SelectedAttributes);\n+ if SelectedAttributes.IsEmpty() then begin\n+ Message('Please select one or more attributes to assign.');\n+ exit;\n+ end;\n+\n+ ItemAttributeAssignment.SetSelectedAttributes(SelectedAttributes);\n+ ItemAttributeAssignment.RunModal();\n+ end;\n+ }\n+ }\n+ area(reporting)\n+ {\n+ action(\"Attribute Usage Report\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'Attribute Usage Report';\n+ Image = \"Report\";\n+ RunObject = Report \"Item Attribute Usage\";\n+ ToolTip = 'View a report showing how attributes are used across items.';\n+ }\n+ }\n+ }\n+\n+ trigger OnOpenPage()\n+ var\n+ FeatureManagement: Codeunit \"Feature Management\";\n+ NotificationManager: Codeunit \"Notification Manager\";\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+ end;\n+\n+ // Check for blank option attributes and show notification\n+ if HasBlankOptionAttributes() then begin\n+ BlankOptionAttributeNotification.Id := CreateGuid();\n+ BlankOptionAttributeNotification.Message := 'Some option attributes have blank values that may cause issues.';\n+ BlankOptionAttributeNotification.Scope := NotificationScope::LocalScope;\n+ BlankOptionAttributeNotification.AddAction('Review Attributes', Codeunit::\"Item Attribute Management\", 'ReviewBlankOptions');\n+ BlankOptionAttributeNotification.Send(); // Using Msg suffix for notification - line 165\n+ end;\n+ end;\n+\n+ trigger OnAfterGetRecord()\n+ begin\n+ // Update attribute statistics\n+ UpdateAttributeStatistics();\n+ end;\n+\n+ trigger OnNewRecord(BelowxRec: Boolean)\n+ begin\n+ Rec.Type := Rec.Type::Text;\n+ Rec.Blocked := false;\n+ end;\n+\n+ local procedure HasBlankOptionAttributes(): Boolean\n+ var\n+ ItemAttribute: Record \"Item Attribute\";\n+ ItemAttributeValue: Record \"Item Attribute Value\";\n+ HasBlankValues: Boolean;\n+ begin\n+ HasBlankValues := false;\n+\n+ ItemAttribute.SetRange(Type, ItemAttribute.Type::Option);\n+ if ItemAttribute.FindSet() then\n+ repeat\n+ ItemAttributeValue.SetRange(\"Attribute ID\", ItemAttribute.ID);\n+ ItemAttributeValue.SetRange(Value, '');\n+ if not ItemAttributeValue.IsEmpty() then begin\n+ HasBlankValues := true;\n+ break;\n+ end;\n+ until ItemAttribute.Next() = 0;\n+\n+ exit(HasBlankValues);\n+ end;\n+\n+ local procedure UpdateAttributeStatistics()\n+ var\n+ ItemAttributeValueMapping: Record \"Item Attribute Value Mapping\";\n+ UsageCount: Integer;\n+ begin\n+ // Count how many items use this attribute\n+ ItemAttributeValueMapping.SetRange(\"Item Attribute ID\", Rec.ID);\n+ UsageCount := ItemAttributeValueMapping.Count();\n+\n+ // Update the usage count field (would need to add this field to the table)\n+ // Rec.\"Usage Count\" := UsageCount;\n+ // Rec.Modify();\n+ end;\n+\n+ procedure SetItemCategoryFilter(ItemCategoryCode: Code[20])\n+ var\n+ ItemCategoryItemAttribute: Record \"Item Category - Item Attribute\";\n+ AttributeFilter: Text;\n+ AttributeId: Integer;\n+ begin\n+ ItemCategoryItemAttribute.SetRange(\"Item Category Code\", ItemCategoryCode);\n+\n+ if ItemCategoryItemAttribute.FindSet() then begin\n+ repeat\n+ AttributeId := ItemCategoryItemAttribute.\"Item Attribute ID\";\n+ if AttributeFilter <> '' then\n+ AttributeFilter := AttributeFilter + '|';\n+ AttributeFilter := AttributeFilter + Format(AttributeId);\n+ until ItemCategoryItemAttribute.Next() = 0;\n+\n+ Rec.SetFilter(ID, AttributeFilter);\n+ end else\n+ Rec.SetRange(ID, -1); // Show no records if category has no attributes\n+ end;\n+\n+ procedure CreateDefaultAttributes()\n+ var\n+ ItemAttribute: Record \"Item Attribute\";\n+ DefaultAttributes: List of [Text];\n+ DefaultTypes: List of [Option];\n+ AttributeName: Text;\n+ i: Integer;\n+ begin\n+ // Define default attributes\n+ DefaultAttributes.Add('Color');\n+ DefaultAttributes.Add('Size');\n+ DefaultAttributes.Add('Material');\n+ DefaultAttributes.Add('Weight');\n+ DefaultAttributes.Add('Dimensions');\n+\n+ for i := 1 to DefaultAttributes.Count() do begin\n+ AttributeName := DefaultAttributes.Get(i);\n+ if not AttributeExists(AttributeName) then begin\n+ ItemAttribute.Init();\n+ ItemAttribute.Name := CopyStr(AttributeName, 1, MaxStrLen(ItemAttribute.Name));\n+\n+ case i of\n+ 1, 2, 3:\n+ ItemAttribute.Type := ItemAttribute.Type::Option;\n+ 4:\n+ begin\n+ ItemAttribute.Type := ItemAttribute.Type::Decimal;\n+ ItemAttribute.\"Unit of Measure\" := 'KG';\n+ end;\n+ 5:\n+ ItemAttribute.Type := ItemAttribute.Type::Text;\n+ end;\n+\n+ ItemAttribute.Insert(true);\n+ end;\n+ end;\n+\n+ Message('%1 default attributes created.', DefaultAttributes.Count());\n+ end;\n+\n+ local procedure AttributeExists(AttributeName: Text): Boolean\n+ var\n+ ItemAttribute: Record \"Item Attribute\";\n+ begin\n+ ItemAttribute.SetRange(Name, AttributeName);\n+ exit(not ItemAttribute.IsEmpty());\n+ end;\n+\n+ var\n+ ImportStream: InStream;\n+ BlankOptionAttributeNotificationMsg: Label 'Some option attributes have blank values that may cause issues.';\n+}\n+\n--- src/SCMSupplyPlanningIV.Codeunit.al\n+++ src/SCMSupplyPlanningIV.Codeunit.al\n+codeunit 50116 \"SCM Supply Planning IV\"\n+{\n+ Subtype = Test;\n+\n+ var\n+ Assert: Codeunit Assert;\n+ LibraryInventory: Codeunit \"Library - Inventory\";\n+ LibraryManufacturing: Codeunit \"Library - Manufacturing\";\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+ // Setup\n+ LibraryInventory.CreateItem(Item);\n+ Item.\"Assembly Policy\" := Item.\"Assembly Policy\"::\"Assemble-to-Order\";\n+ Item.Modify(true);\n+\n+ // Exercise\n+ OrderNo := CreateAssemblyOrderForItem(Item.\"No.\", 10);\n+\n+ // Verify\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); // Wrong suffix usage - line 58\n+ end;\n+\n+ [Test]\n+ procedure TestAssemblyOrderQuantityValidation()\n+ var\n+ Item: Record Item;\n+ AssemblyHeader: Record \"Assembly Header\";\n+ OrderNo: Code[20];\n+ ExpectedQuantity: Decimal;\n+ begin\n+ // Setup\n+ LibraryInventory.CreateItem(Item);\n+ ExpectedQuantity := 25;\n+\n+ // Exercise\n+ OrderNo := CreateAssemblyOrderForItem(Item.\"No.\", ExpectedQuantity);\n+\n+ // Verify\n+ AssemblyHeader.Get(AssemblyHeader.\"Document Type\"::Order, OrderNo);\n+ Assert.AreEqual(ExpectedQuantity, AssemblyHeader.Quantity,\n+ 'Assembly order quantity should match expected value');\n+ end;\n+\n+ [Test]\n+ procedure TestMultipleAssemblyOrders()\n+ var\n+ Item: Record Item;\n+ AssemblyHeader: Record \"Assembly Header\";\n+ OrderCount: Integer;\n+ i: Integer;\n+ OrderNo: Code[20];\n+ begin\n+ // Setup\n+ LibraryInventory.CreateItem(Item);\n+ OrderCount := 5;\n+\n+ // Exercise\n+ for i := 1 to OrderCount do begin\n+ OrderNo := CreateAssemblyOrderForItem(Item.\"No.\", 10 * i);\n+\n+ // Verify each order\n+ AssemblyHeader.Get(AssemblyHeader.\"Document Type\"::Order, OrderNo);\n+ Assert.AreEqual(Item.\"No.\", AssemblyHeader.\"Item No.\", 'Item should match for order ' + Format(i));\n+ end;\n+\n+ // Verify total count\n+ AssemblyHeader.SetRange(\"Item No.\", Item.\"No.\");\n+ Assert.AreEqual(OrderCount, AssemblyHeader.Count(), 'Should have created expected number of orders');\n+ end;\n+\n+ local procedure CreateAssemblyOrderForItem(ItemNo: Code[20]; Quantity: Decimal): Code[20]\n+ var\n+ AssemblyHeader: Record \"Assembly Header\";\n+ NoSeriesManagement: Codeunit NoSeriesManagement;\n+ begin\n+ AssemblyHeader.Init();\n+ AssemblyHeader.\"Document Type\" := AssemblyHeader.\"Document Type\"::Order;\n+ AssemblyHeader.\"No.\" := NoSeriesManagement.GetNextNo(GetAssemblyOrderNos(), Today, true);\n+ AssemblyHeader.\"Item No.\" := ItemNo;\n+ AssemblyHeader.Quantity := Quantity;\n+ AssemblyHeader.\"Due Date\" := CalcDate('<+7D>', Today);\n+ AssemblyHeader.Insert(true);\n+\n+ exit(AssemblyHeader.\"No.\");\n+ end;\n+\n+ local procedure GetAssemblyOrderNos(): Code[20]\n+ var\n+ AssemblySetup: Record \"Assembly Setup\";\n+ begin\n+ AssemblySetup.Get();\n+ exit(AssemblySetup.\"Assembly Order Nos.\");\n+ end;\n+}\n+", "expected_comments": [{"file": "src/CloudMigReplicateDataMgt.Codeunit.al", "line_start": 28, "line_end": 28, "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. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/CustStPDFDocHandler.Codeunit.al", "line_start": 9, "line_end": 9, "body": "Label 'UnableToProcessDocumentErr' has a trailing comma in the message text: 'Unable to process document with SystemId %1,' - the comma appears to be a typo in the label value. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/CustStPDFDocHandler.Codeunit.al", "line_start": 45, "line_end": 45, "body": "Error() is being called with a label that has a trailing comma in the message: 'SystemId %1,' - the comma appears to be a typo. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseCategory.Table.al", "line_start": 207, "line_end": 207, "body": "Using Error('') with empty string is not recommended (CodeCop AA0216). Error messages should use label variables with proper suffix. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ItemCategoryAttributes.Page.al", "line_start": 155, "line_end": 155, "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. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/SCMSupplyPlanningIV.Codeunit.al", "line_start": 30, "line_end": 30, "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. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/CloudMigReplicateDataMgt.Codeunit.al", "line_start": 60, "line_end": 60, "body": "Hardcoded text string in Message() call (CodeCop AA0217): 'Replication enabled for %1 of %2 tables' should use a Label with Msg suffix. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/CustStPDFDocHandler.Codeunit.al", "line_start": 51, "line_end": 51, "body": "Hardcoded error message string in Error() call (CodeCop AA0217): 'Unable to create customer statement record for customer %1'. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/CustStPDFDocHandler.Codeunit.al", "line_start": 111, "line_end": 111, "body": "Inconsistent indentation in try..except block: 'GenerationSuccess := ...' is not indented to match the try block level. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/CustStPDFDocHandler.Codeunit.al", "line_start": 177, "line_end": 177, "body": "Unreachable code (AA0136): 'ValidationResult := false' after Error() on the previous line will never execute. \u2014 See agent comment for details.", "severity": "low"}, {"file": "src/CustStPDFDocHandler.Codeunit.al", "line_start": 176, "line_end": 176, "body": "Hardcoded error message strings in Error() calls in ValidateStatementParameters (CodeCop AA0217). Multiple Error() calls use inline strings. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseCategory.Table.al", "line_start": 54, "line_end": 54, "body": "Hardcoded error message string in Error() call: 'Maximum amount cannot be negative' (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseCategory.Table.al", "line_start": 183, "line_end": 183, "body": "Hardcoded error message string in Error() call: 'Cannot delete expense category %1 because it is used in expense lines' (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseCategory.Table.al", "line_start": 194, "line_end": 194, "body": "Hardcoded error message strings in Error() calls in ValidateGLAccount (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseCategory.Table.al", "line_start": 210, "line_end": 210, "body": "Hardcoded error message strings in Error() calls in ValidateExpenseCategory (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ItemCategoryAttributes.Page.al", "line_start": 87, "line_end": 87, "body": "Hardcoded message strings in Message() calls (CodeCop AA0217). Multiple Message() calls use inline strings instead of Label variables. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ItemCategoryAttributes.Page.al", "line_start": 291, "line_end": 291, "body": "Label variable BlankOptionAttributeNotificationMsg is declared but the hardcoded string is used directly on line 165 instead of using the label. \u2014 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": "style-014", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/ReportFormatting.Codeunit.al\n+++ src/ReportFormatting.Codeunit.al\n+codeunit 50302 \"Report Formatting\"\n+{\n+ local procedure RunBatchProcess()\n+ var\n+ Customer: Record Customer;\n+ ProcessedCount: Integer;\n+ begin\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": 9, "line_end": 9, "body": "Hardcoded error string 'No customers found for processing.' in Error() call instead of using a Label variable with Err suffix (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/ReportFormatting.Codeunit.al", "line_start": 14, "line_end": 14, "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). \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/ReportFormatting.Codeunit.al", "line_start": 17, "line_end": 17, "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). \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/ReportFormatting.Codeunit.al", "line_start": 18, "line_end": 18, "body": "Hardcoded message string 'Successfully processed %1 customers.' in Message() call instead of using a Label variable with Msg suffix (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/ReportFormatting.Codeunit.al", "line_start": 26, "line_end": 26, "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). \u2014 See agent comment for details.", "severity": "high"}], "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": "style-015", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/NamingViolations.Codeunit.al\n+++ src/NamingViolations.Codeunit.al\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+", "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. \u2014 See agent comment for details.", "severity": "high"}, {"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. \u2014 See agent comment for details.", "severity": "medium"}, {"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'). \u2014 See agent comment for details.", "severity": "medium"}, {"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. \u2014 See agent comment for details.", "severity": "high"}], "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": "style-016", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/PriceCalculationHandler.Enum.al\n+++ src/PriceCalculationHandler.Enum.al\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+#if not CLEAN27\n+ ObsoleteState = Pending;\n+ ObsoleteReason = 'Replaced by the new price calculation engine introduced in Business Central 2021 Wave 1.';\n+ ObsoleteTag = '16.0'; // Wrong ObsoleteTag version - line 30\n+#endif\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+}\n--- src/ReqWorksheetTemplateType.Enum.al\n+++ src/ReqWorksheetTemplateType.Enum.al\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+}\n--- src/WHTPstdPurchTaxCrMemos.Page.al\n+++ src/WHTPstdPurchTaxCrMemos.Page.al\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+ ObsoleteState = Removed;\n+ ObsoleteReason = 'This page has been replaced by the new Withholding Tax Posted Credit Memo List page to align with updated tax reporting requirements.';\n+ ObsoleteTag = '25.0';\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.'); // Line 73 - Hardcoded string\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+}", "expected_comments": [{"file": "src/PriceCalculationHandler.Enum.al", "line_start": 30, "line_end": 30, "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. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ReqWorksheetTemplateType.Enum.al", "line_start": 8, "line_end": 8, "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 \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/WHTPstdPurchTaxCrMemos.Page.al", "line_start": 147, "line_end": 147, "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. \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/WHTPstdPurchTaxCrMemos.Page.al", "line_start": 117, "line_end": 117, "body": "Unused variable 'EmailManagement' declared but never referenced in the trigger body (CodeCop AA0137). \u2014 See agent comment for details.", "severity": "medium"}], "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": "style-017", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "--- src/ExpenseTeams.Page.al\n+++ src/ExpenseTeams.Page.al\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+ // Missing AboutTitle and AboutText properties - line 14\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+ part(TeamStatisticsFactBox; \"Expense Team Statistics\")\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(SetupApprovalWorkflow)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Setup Approval Workflow';\n+ Image = Workflow;\n+ ToolTip = 'Configure the approval workflow for expenses submitted by team members.';\n+\n+ trigger OnAction()\n+ var\n+ WorkflowSetup: Codeunit \"Workflow Setup\";\n+ begin\n+ WorkflowSetup.SetupExpenseApprovalWorkflow(Rec.Code);\n+ end;\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+ ExpenseHeader: Record \"Expense Header\";\n+ ExpenseTeamMember: Record \"Expense Team Member\";\n+ FilterText: Text;\n+ begin\n+ ExpenseTeamMember.SetRange(\"Team Code\", Rec.Code);\n+ if ExpenseTeamMember.FindSet() then begin\n+ repeat\n+ if FilterText <> '' then\n+ FilterText := FilterText + '|';\n+ FilterText := FilterText + ExpenseTeamMember.\"User ID\";\n+ until ExpenseTeamMember.Next() = 0;\n+\n+ ExpenseHeader.SetFilter(\"Submitted By\", FilterText);\n+ Page.Run(Page::\"Expense Headers\", ExpenseHeader);\n+ end else\n+ Message('No members found for team %1', Rec.Code);\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+ action(ApprovalEntries)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Approval Entries';\n+ Image = Approvals;\n+ RunObject = Page \"Approval Entries\";\n+ RunPageLink = \"Related Record ID\" = field(SystemId);\n+ ToolTip = 'View approval entries related to this expense team.';\n+ }\n+ }\n+ }\n+ area(Reporting)\n+ {\n+ action(TeamExpenseReport)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Team Expense Report';\n+ Image = \"Report\";\n+ RunObject = Report \"Team Expense Summary\";\n+ RunPageLink = Code = field(Code);\n+ ToolTip = 'Generate a comprehensive expense report for the selected team.';\n+ }\n+ action(TeamPerformanceReport)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Team Performance Report';\n+ Image = \"Report\";\n+ RunObject = Report \"Team Performance Analysis\";\n+ RunPageLink = \"Team Code\" = field(Code);\n+ ToolTip = 'Generate a performance analysis report for the selected team.';\n+ }\n+ }\n+ }\n+\n+ trigger OnNewRecord(BelowxRec: Boolean)\n+ begin\n+ Rec.Active := true;\n+ Rec.\"Max Approval Amount\" := 5000; // Default maximum approval amount\n+ end;\n+\n+ trigger OnAfterGetRecord()\n+ begin\n+ UpdateTeamStatistics();\n+ end;\n+\n+ trigger OnAfterGetCurrRecord()\n+ begin\n+ CurrPage.TeamMembersFactBox.PAGE.UpdateMemberCount(Rec.Code);\n+ CurrPage.TeamStatisticsFactBox.PAGE.UpdateStatistics(Rec.Code);\n+ end;\n+\n+ local procedure UpdateTeamStatistics()\n+ var\n+ ExpenseTeamMember: Record \"Expense Team Member\";\n+ MemberCount: Integer;\n+ begin\n+ ExpenseTeamMember.SetRange(\"Team Code\", Rec.Code);\n+ MemberCount := ExpenseTeamMember.Count();\n+\n+ Rec.\"Member Count\" := MemberCount;\n+ Rec.Modify();\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": 11, "line_end": 11, "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. \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseTeams.Page.al", "line_start": 120, "line_end": 120, "body": "Hardcoded string in Message() call: 'No members found for team %1' should use a Label variable with Msg suffix (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseTeams.Page.al", "line_start": 234, "line_end": 234, "body": "Hardcoded string in Message() call: '%1 default expense teams have been created.' should use a Label variable with Msg suffix (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseTeams.Page.al", "line_start": 245, "line_end": 245, "body": "Hardcoded string 'Team Leader must be specified.' in error path should use a Label variable with Err suffix (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseTeams.Page.al", "line_start": 250, "line_end": 250, "body": "Hardcoded string 'Default Approver must be specified.' in error path should use a Label variable with Err suffix (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseTeams.Page.al", "line_start": 255, "line_end": 255, "body": "Hardcoded string 'Max Approval Amount must be greater than zero.' in error path should use a Label variable with Err suffix (CodeCop AA0217). \u2014 See agent comment for details.", "severity": "medium"}], "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": "upgrade-001", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "--- src/NewFeaturePageExt.PageExt.al\n+++ src/NewFeaturePageExt.PageExt.al\n+pageextension 50100 NewFeaturePageExt extends \"Customer Card\"\n+{\n+ layout\n+ {\n+ addafter(\"Name\")\n+ {\n+ field(\"Customer Status\"; CustomerStatus)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Customer Status';\n+\n+ // This LOOKS like a breaking change because we're adding a new required field\n+ // that could affect existing integrations, but it's actually NOT breaking because:\n+ // 1. It's a page extension, not a table field modification\n+ // 2. Page extensions are additive and don't break existing API contracts\n+ // 3. The field is only visible in UI, not in the underlying data structure\n+ // 4. Existing code accessing Customer table won't be affected\n+ trigger OnValidate()\n+ begin\n+ if CustomerStatus = CustomerStatus::Blocked then\n+ Message('Customer is now blocked for further transactions');\n+ end;\n+ }\n+ }\n+ }\n+\n+ var\n+ CustomerStatus: Option Open,Blocked,\"On Hold\";\n+\n+ trigger OnAfterGetRecord()\n+ begin\n+ // This initialization looks problematic but is safe because:\n+ // - We're not modifying existing table data\n+ // - This only affects the page display logic\n+ // - It doesn't change any existing customer records\n+ CustomerStatus := GetCustomerStatusFromCreditLimit();\n+ end;\n+\n+ local procedure GetCustomerStatusFromCreditLimit(): Integer\n+ begin\n+ // Determining status based on existing fields is NOT a breaking change\n+ // because we're reading existing data, not modifying the schema\n+ if Rec.\"Credit Limit (LCY)\" = 0 then\n+ exit(CustomerStatus::Blocked.AsInteger());\n+\n+ if Rec.\"Balance (LCY)\" > Rec.\"Credit Limit (LCY)\" then\n+ exit(CustomerStatus::\"On Hold\".AsInteger());\n+\n+ exit(CustomerStatus::Open.AsInteger());\n+ end;\n+}", "expected_comments": [], "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": "upgrade-002", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "--- src/EnumConversionHelper.Codeunit.al\n+++ src/EnumConversionHelper.Codeunit.al\n+codeunit 50101 EnumConversionHelper\n+{\n+ procedure ConvertPaymentMethodToEnum(PaymentMethod: Code[20]): Enum \"Payment Method Type\"\n+ begin\n+ // This LOOKS like a risky enum conversion because we're converting from\n+ // a flexible Code field to a constrained enum, but it's actually SAFE because:\n+ // 1. We have comprehensive fallback handling for all cases\n+ // 2. The conversion maintains backward compatibility\n+ // 3. Unknown values are gracefully handled, not lost\n+ // 4. This is an additive change that extends functionality\n+\n+ case PaymentMethod of\n+ 'CASH':\n+ exit(\"Payment Method Type\"::Cash);\n+ 'CHECK', 'CHEQUE':\n+ exit(\"Payment Method Type\"::Check);\n+ 'CARD', 'CREDIT':\n+ exit(\"Payment Method Type\"::\"Credit Card\");\n+ 'BANK', 'TRANSFER', 'WIRE':\n+ exit(\"Payment Method Type\"::\"Bank Transfer\");\n+ 'ELECTRONIC', 'EFT', 'ACH':\n+ exit(\"Payment Method Type\"::Electronic);\n+ else begin\n+ // SAFE: Unknown payment methods are preserved via Other category\n+ // This prevents data loss and maintains system stability\n+ // Legacy integrations continue to work without modification\n+ exit(\"Payment Method Type\"::Other);\n+ end;\n+ end;\n+ end;\n+\n+ procedure GetLegacyPaymentCode(PaymentMethodType: Enum \"Payment Method Type\"): Code[20]\n+ begin\n+ // This reverse conversion ensures complete backward compatibility\n+ // Old reports and integrations can still access the original codes\n+ case PaymentMethodType of\n+ \"Payment Method Type\"::Cash:\n+ exit('CASH');\n+ \"Payment Method Type\"::Check:\n+ exit('CHECK');\n+ \"Payment Method Type\"::\"Credit Card\":\n+ exit('CARD');\n+ \"Payment Method Type\"::\"Bank Transfer\":\n+ exit('BANK');\n+ \"Payment Method Type\"::Electronic:\n+ exit('ELECTRONIC');\n+ \"Payment Method Type\"::Other:\n+ exit('OTHER');\n+ else\n+ Error('Unknown payment method type %1.', PaymentMethodType);\n+ end;\n+ end;\n+}\n--- src/PaymentMethodType.Enum.al\n+++ src/PaymentMethodType.Enum.al\n+// New enum definition \u2014 not a conversion of an existing Option field.\n+// No upgrade codeunit is needed because there is no legacy data to migrate.\n+enum 50100 \"Payment Method Type\"\n+{\n+ Extensible = true; // CRITICAL: This makes it safe for future additions\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+ // This catch-all value ensures NO data loss during conversion\n+ // It's the safety net that makes this enum conversion NON-breaking\n+ // Any unmapped legacy values flow here instead of causing errors\n+ }\n+}\n+\n+// This LOOKS like a dangerous breaking change because we're replacing\n+// flexible text fields with constrained enum values, but it's actually\n+// SAFE and NON-breaking because:\n+//\n+// 1. EXTENSIBLE DESIGN: The enum is marked as Extensible = true,\n+// allowing partners to add values without recompiling\n+//\n+// 2. COMPREHENSIVE MAPPING: All known payment method codes are mapped\n+// to appropriate enum values with consistent logic\n+//\n+// 3. FALLBACK PROTECTION: The \"Other\" value (99) catches any unmapped\n+// legacy values, preventing data loss or system failures\n+//\n+// 4. BACKWARD COMPATIBILITY: Helper methods provide reverse conversion\n+// so existing integrations continue to work\n+//\n+// 5. NO FORCED MIGRATION: Existing tables can keep Code fields and\n+// convert on-the-fly when needed for new functionality", "expected_comments": [], "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": "upgrade-003", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "--- src/CustomerListEnhancements.PageExt.al\n+++ src/CustomerListEnhancements.PageExt.al\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+\n+ // This LOOKS like an obsolete usage concern because we're using\n+ // ExcelBuffer which might seem outdated, but it's actually CORRECT:\n+ // 1. ExcelBuffer is still the official supported method for Excel export\n+ // 2. This is not an obsolete API - it's the current standard approach\n+ // 3. The functionality works correctly with modern Excel versions\n+ // 4. Microsoft maintains this as the recommended Excel integration pattern\n+\n+ trigger OnAction()\n+ var\n+ Customer: Record Customer;\n+ ExcelBuffer: Record \"Excel Buffer\" temporary;\n+ RowNo: Integer;\n+ begin\n+ ExcelBuffer.DeleteAll();\n+ RowNo := 1;\n+\n+ // Header row - this is the standard, supported approach\n+ ExcelBuffer.NewRow();\n+ ExcelBuffer.AddColumn('Customer No.', false, '', false, false, false, '', ExcelBuffer.\"Cell Type\"::Text);\n+ ExcelBuffer.AddColumn('Name', false, '', false, false, false, '', ExcelBuffer.\"Cell Type\"::Text);\n+ ExcelBuffer.AddColumn('Credit Limit', false, '', false, false, false, '', ExcelBuffer.\"Cell Type\"::Text);\n+\n+ // Data rows using current AL patterns\n+ Customer.Copy(Rec);\n+ if Customer.FindSet() then\n+ repeat\n+ ExcelBuffer.NewRow();\n+ ExcelBuffer.AddColumn(Customer.\"No.\", false, '', false, false, false, '', ExcelBuffer.\"Cell Type\"::Text);\n+ ExcelBuffer.AddColumn(Customer.Name, false, '', false, false, false, '', ExcelBuffer.\"Cell Type\"::Text);\n+ ExcelBuffer.AddColumn(Customer.\"Credit Limit (LCY)\", false, '', false, false, false, '', ExcelBuffer.\"Cell Type\"::Number);\n+ until Customer.Next() = 0;\n+\n+ ExcelBuffer.CreateNewBook('Customer List');\n+ ExcelBuffer.WriteSheet('Customers', CompanyName, UserId);\n+ ExcelBuffer.CloseBook();\n+ ExcelBuffer.OpenExcel();\n+ end;\n+ }\n+ }\n+ }\n+}\n--- src/ModernAPIHelper.Codeunit.al\n+++ src/ModernAPIHelper.Codeunit.al\n+codeunit 50102 ModernAPIHelper\n+{\n+ [Obsolete('Use GetCustomerDataV2() instead', '25.0')]\n+ procedure GetCustomerData(CustomerNo: Code[20]): Text\n+ begin\n+ // This LOOKS like problematic obsolete usage because we're calling\n+ // an obsolete procedure, but it's actually CORRECT because:\n+ // 1. This procedure itself is marked as obsolete (being phased out) \n+ // 2. It provides backward compatibility during transition period\n+ // 3. The obsolete call is encapsulated within another obsolete method\n+ // 4. This is the documented migration pattern for gradual deprecation\n+\n+ exit(GetLegacyCustomerInfo(CustomerNo));\n+ end;\n+\n+ procedure GetCustomerDataV2(CustomerNo: Code[20]): JsonObject\n+ var\n+ Customer: Record Customer;\n+ JsonResult: JsonObject;\n+ begin\n+ // This is the NEW version that replaces the obsolete method above\n+ // It uses modern JsonObject instead of plain text\n+ if Customer.Get(CustomerNo) then begin\n+ JsonResult.Add('CustomerNo', Customer.\"No.\");\n+ JsonResult.Add('Name', Customer.Name);\n+ JsonResult.Add('CreditLimit', Customer.\"Credit Limit (LCY)\");\n+ JsonResult.Add('Balance', Customer.\"Balance (LCY)\");\n+ end;\n+ exit(JsonResult);\n+ end;\n+\n+ [Obsolete('Internal helper method, use GetCustomerDataV2() instead', '25.0')]\n+ local procedure GetLegacyCustomerInfo(CustomerNo: Code[20]): Text\n+ var\n+ Customer: Record Customer;\n+ begin\n+ // This obsolete method is CORRECTLY used by the obsolete public method\n+ // during the transition period. This is NOT a violation because:\n+ // 1. Both calling and called methods are marked obsolete together\n+ // 2. This maintains functionality while guiding users to new API\n+ // 3. Internal obsolete-to-obsolete calls are part of migration strategy\n+\n+ if Customer.Get(CustomerNo) then\n+ exit(StrSubstNo('%1|%2|%3', Customer.\"No.\", Customer.Name, Customer.\"Credit Limit (LCY)\"));\n+ exit('');\n+ end;\n+}", "expected_comments": [], "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": "upgrade-004", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "--- src/GenericUpgradeHandler.Codeunit.al\n+++ src/GenericUpgradeHandler.Codeunit.al\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+ UpgradeTag: Codeunit \"Upgrade Tag\";\n+ CompanyInfo: Record \"Company Information\";\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+}\n--- src/MigrationStatusTracker.Table.al\n+++ src/MigrationStatusTracker.Table.al\n+table 50105 \"Migration Status Tracker\"\n+{\n+ DataClassification = SystemMetadata;\n+ TableType = Temporary; // SAFE: Temporary table for tracking migration progress\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+ Caption = 'Migration Status';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Migration ID\", \"Table Name\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ // This LOOKS like it might cause upgrade issues because we're creating\n+ // a new table during upgrade, but it's actually COMPLETELY SAFE because:\n+ // 1. It's a TEMPORARY table - no persistent database changes\n+ // 2. Used only for tracking migration progress in memory \n+ // 3. No impact on existing data or table structures\n+ // 4. Follows Microsoft patterns for upgrade status tracking\n+ // 5. Can be removed without any data loss after upgrade completes\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();\n+\n+ exit(MigrationId);\n+ end;\n+\n+ procedure UpdateProgress(MigrationId: Guid; TableName: Text[100]; RecordsProcessed: Integer)\n+ begin\n+ // Safe progress tracking - no risk to existing data\n+ if Rec.Get(MigrationId, TableName) then begin\n+ Rec.\"Records Processed\" := RecordsProcessed;\n+ Rec.Status := Rec.Status::InProgress;\n+ Rec.Modify();\n+ end;\n+ end;\n+}", "expected_comments": [], "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": "upgrade-005", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "--- src/CurrencySymbolPosition.Enum.al\n+++ src/CurrencySymbolPosition.Enum.al\n+/// \n+/// Currency Symbol Position Enum (764)\n+/// Defines where the currency symbol should be positioned relative to amounts\n+/// \n+enum 764 \"Currency Symbol Position\"\n+{\n+ Extensible = true;\n+\n+ value(0; \"Default\") // New value added, changing existing ordinals\n+ {\n+ Caption = 'Default';\n+ }\n+\n+ value(1; \"Before Amount\") // Changed from ordinal 0 to 1 - breaking change\n+ {\n+ Caption = 'Before Amount';\n+ }\n+\n+ value(2; \"After Amount\") // Changed from ordinal 1 to 2 - breaking change\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+}\n--- src/ManufacturingSetup.Table.al\n+++ src/ManufacturingSetup.Table.al\n+/// \n+/// Manufacturing Setup Table (99000765)\n+/// Stores manufacturing configuration and setup parameters\n+/// \n+table 99000765 \"Manufacturing Setup\"\n+{\n+ Caption = 'Manufacturing Setup';\n+ DataPerCompany = true;\n+ Permissions = TableData \"Manufacturing Setup\" = rimd;\n+\n+ fields\n+ {\n+ field(1; \"Primary Key\"; Code[10])\n+ {\n+ Caption = 'Primary Key';\n+ NotBlank = true;\n+ }\n+\n+ field(10; \"Planning Worksheet Template\"; Code[10])\n+ {\n+ Caption = 'Planning Worksheet Template';\n+ TableRelation = \"Req. Wksh. Template\";\n+ }\n+\n+ field(11; \"Planning Worksheet Batch\"; Code[10])\n+ {\n+ Caption = 'Planning Worksheet Batch';\n+ TableRelation = \"Requisition Wksh. Name\".Name WHERE(\"Worksheet Template Name\" = FIELD(\"Planning Worksheet Template\"));\n+ }\n+\n+ field(12; \"Requisition Worksheet Template\"; Code[10])\n+ {\n+ Caption = 'Requisition Worksheet Template';\n+ TableRelation = \"Req. Wksh. Template\";\n+ }\n+\n+ field(13; \"Requisition Worksheet Batch\"; Code[10])\n+ {\n+ Caption = 'Requisition Worksheet Batch';\n+ TableRelation = \"Requisition Wksh. Name\".Name WHERE(\"Worksheet Template Name\" = FIELD(\"Requisition Worksheet Template\"));\n+ }\n+\n+ field(20; \"Default Damping Period\"; DateFormula)\n+ {\n+ Caption = 'Default Damping Period';\n+ }\n+\n+ field(21; \"Default Damping %\"; Decimal)\n+ {\n+ Caption = 'Default Damping %';\n+ DecimalPlaces = 0 : 5;\n+ MinValue = 0;\n+ MaxValue = 100;\n+ }\n+\n+ field(22; \"Default Safety Lead Time\"; DateFormula)\n+ {\n+ Caption = 'Default Safety Lead Time';\n+ }\n+\n+ field(325; \"Copy Loc. to Cap. Val. Entries\"; Boolean)\n+ {\n+ Caption = 'Copy Location Code to Capacity Value Entries';\n+ InitValue = true; // This is the bad pattern - InitValue changes behavior for existing records\n+ }\n+\n+ field(326; \"Enable Advanced Costing\"; Boolean)\n+ {\n+ Caption = 'Enable Advanced Costing';\n+ }\n+\n+ field(327; \"Production Order Line Batch\"; Code[10])\n+ {\n+ Caption = 'Production Order Line Batch';\n+ TableRelation = \"Item Journal Batch\".Name WHERE(\"Journal Template Name\" = CONST('ITEM'));\n+ }\n+\n+ field(328; \"Machine Center Nos.\"; Code[20])\n+ {\n+ Caption = 'Machine Center Nos.';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(329; \"Work Center Nos.\"; Code[20])\n+ {\n+ Caption = 'Work Center Nos.';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(330; \"Production Order Nos.\"; Code[20])\n+ {\n+ Caption = 'Production Order Nos.';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(331; \"Simulated Order Nos.\"; Code[20])\n+ {\n+ Caption = 'Simulated Order Nos.';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(332; \"Planned Order Nos.\"; Code[20])\n+ {\n+ Caption = 'Planned Order Nos.';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(333; \"Firm Planned Order Nos.\"; Code[20])\n+ {\n+ Caption = 'Firm Planned Order Nos.';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(334; \"Released Order Nos.\"; Code[20])\n+ {\n+ Caption = 'Released Order Nos.';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(335; \"Finished Order Nos.\"; Code[20])\n+ {\n+ Caption = 'Finished Order Nos.';\n+ TableRelation = \"No. Series\";\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Primary Key\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ InitManufacturingSetup();\n+ end;\n+\n+ local procedure InitManufacturingSetup()\n+ var\n+ NoSeriesMgt: Codeunit NoSeriesManagement;\n+ begin\n+ if \"Machine Center Nos.\" = '' then\n+ \"Machine Center Nos.\" := NoSeriesMgt.GetDefaultNoSeriesCode(DATABASE::\"Machine Center\", UserId);\n+\n+ if \"Work Center Nos.\" = '' then\n+ \"Work Center Nos.\" := NoSeriesMgt.GetDefaultNoSeriesCode(DATABASE::\"Work Center\", UserId);\n+ end;\n+}\n--- src/O365Contact.Table.al\n+++ src/O365Contact.Table.al\n+/// \n+/// O365 Contact Table (5367)\n+/// Stores contact synchronization data with Office 365\n+/// \n+table 5367 \"O365 Contact\"\n+{\n+ Caption = 'O365 Contact';\n+ ObsoleteState = Removed;\n+ ObsoleteReason = 'Replaced with new Office 365 integration';\n+ ReplicateData = false;\n+\n+ fields\n+ {\n+ field(1; \"Contact ID\"; Text[250])\n+ {\n+ Caption = 'Contact ID';\n+ Editable = false;\n+ }\n+\n+ field(2; \"Contact No.\"; Code[20])\n+ {\n+ Caption = 'Contact No.';\n+ TableRelation = Contact;\n+ }\n+\n+ field(3; Name; Text[100])\n+ {\n+ Caption = 'Name';\n+ }\n+\n+ field(4; \"E-Mail\"; Text[80])\n+ {\n+ Caption = 'E-Mail';\n+ ExtendedDatatype = EMail;\n+ }\n+\n+ field(5; \"Phone No.\"; Text[30])\n+ {\n+ Caption = 'Phone No.';\n+ }\n+\n+ field(6; \"Mobile Phone No.\"; Text[30])\n+ {\n+ Caption = 'Mobile Phone No.';\n+ }\n+\n+ field(10; Type; Option)\n+ {\n+ Caption = 'Type';\n+ OptionCaption = 'Company,Person';\n+ OptionMembers = Company,Person;\n+ }\n+\n+ field(11; \"Is Modified\"; Boolean)\n+ {\n+ Caption = 'Is Modified';\n+ }\n+\n+ field(12; \"Last Modified Date Time\"; DateTime)\n+ {\n+ Caption = 'Last Modified Date Time';\n+ }\n+\n+ field(104; OutlookId; Text[250])\n+ {\n+ Caption = 'Outlook ID';\n+ Editable = false;\n+ }\n+\n+ field(105; \"Office 365 ID\"; Text[250])\n+ {\n+ Caption = 'Office 365 ID';\n+ Editable = false;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; OutlookId) // This is the bad pattern - primary key changed from \"Contact ID\" to OutlookId\n+ {\n+ Clustered = true;\n+ }\n+\n+ key(Key2; \"Contact No.\")\n+ {\n+ }\n+\n+ key(Key3; \"E-Mail\")\n+ {\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ \"Last Modified Date Time\" := CurrentDateTime;\n+ \"Is Modified\" := true;\n+ end;\n+\n+ trigger OnModify()\n+ begin\n+ \"Last Modified Date Time\" := CurrentDateTime;\n+ \"Is Modified\" := true;\n+ end;\n+\n+ trigger OnDelete()\n+ var\n+ Contact: Record Contact;\n+ begin\n+ if Contact.Get(\"Contact No.\") then begin\n+ Contact.\"Office 365 Contact ID\" := '';\n+ Contact.Modify();\n+ end;\n+ end;\n+\n+ procedure SyncWithOffice365()\n+ var\n+ Office365SyncMgt: Codeunit \"Office 365 Sync. Management\";\n+ begin\n+ Office365SyncMgt.SyncContact(Rec);\n+ end;\n+\n+ procedure UpdateFromContact(Contact: Record Contact)\n+ begin\n+ Name := Contact.Name;\n+ \"E-Mail\" := Contact.\"E-Mail\";\n+ \"Phone No.\" := Contact.\"Phone No.\";\n+ \"Mobile Phone No.\" := Contact.\"Mobile Phone No.\";\n+\n+ if Contact.Type = Contact.Type::Company then\n+ Type := Type::Company\n+ else\n+ Type := Type::Person;\n+ end;\n+}\n--- src/PostedExpenseReportLine.Table.al\n+++ src/PostedExpenseReportLine.Table.al\n+/// \n+/// Posted Expense Report Line Table (6913)\n+/// Contains posted expense report lines after approval and posting\n+/// \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 renumbering detected - this is the bad pattern\n+ field(1; \"Document No.\"; Code[20]) // Changed from field(3) to field(1)\n+ {\n+ Caption = 'Document No.';\n+ TableRelation = \"Posted Expense Report Header\";\n+ }\n+\n+ field(2; \"Line No.\"; Integer) // Changed from field(4) to field(2) \n+ {\n+ Caption = 'Line No.';\n+ }\n+\n+ field(3; \"Employee Code\"; Code[20]) // Changed from field(1) to field(3)\n+ {\n+ Caption = 'Employee Code';\n+ TableRelation = Employee;\n+ }\n+\n+ field(4; \"Expense No.\"; Code[20]) // Changed from field(2) to field(4)\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+}\n--- src/TaxTransactionValue.Table.al\n+++ src/TaxTransactionValue.Table.al\n+/// \n+/// Tax Transaction Value Table (18221)\n+/// Stores tax transaction values and calculations\n+/// \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) // Changed from Integer to BigInteger - breaking change\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; \"Line No\"; Integer)\n+ {\n+ Caption = '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+}", "expected_comments": [{"file": "src/CurrencySymbolPosition.Enum.al", "line_start": 9, "line_end": 9, "body": "Enum value re-numbering - Before Amount changed from 0 to 1 \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/ManufacturingSetup.Table.al", "line_start": 64, "line_end": 64, "body": "InitValue = true on new Boolean field without upgrade code \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/O365Contact.Table.al", "line_start": 79, "line_end": 79, "body": "Primary key changed from 'Contact ID' to OutlookId \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/PostedExpenseReportLine.Table.al", "line_start": 15, "line_end": 15, "body": "Field renumbering - Document No. from field(3) to field(1) \u2014 See agent comment for details.", "severity": "critical"}, {"file": "src/TaxTransactionValue.Table.al", "line_start": 108, "line_end": 108, "body": "Field type change from Integer to BigInteger without upgrade code \u2014 See agent comment for details.", "severity": "critical"}], "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": "upgrade-006", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "--- src/ExpensePaymentMethod.Table.al\n+++ src/ExpensePaymentMethod.Table.al\n+/// \n+/// Expense Payment Method Table (6913)\n+/// New table for expense-specific payment methods\n+/// Replaces the use of Payment Method table for expenses\n+/// \n+table 6913 \"Expense Payment Method\"\n+{\n+ Caption = 'Expense Payment Method';\n+ DataClassification = CustomerContent;\n+ LookupPageID = \"Expense Payment Methods\";\n+ DrillDownPageID = \"Expense Payment Methods\";\n+\n+ fields\n+ {\n+ field(1; \"Code\"; Code[10])\n+ {\n+ Caption = 'Code';\n+ NotBlank = true;\n+ }\n+\n+ field(2; Description; Text[100])\n+ {\n+ Caption = 'Description';\n+ }\n+\n+ field(3; \"Reimbursable\"; Boolean)\n+ {\n+ Caption = 'Reimbursable';\n+ }\n+\n+ field(4; \"Corporate Card\"; Boolean)\n+ {\n+ Caption = 'Corporate Card';\n+ }\n+\n+ field(5; \"Requires Receipt\"; Boolean)\n+ {\n+ Caption = 'Requires Receipt';\n+ }\n+\n+ field(6; \"Default G/L Account\"; Code[20])\n+ {\n+ Caption = 'Default G/L Account';\n+ TableRelation = \"G/L Account\" WHERE(\"Account Type\" = CONST(Posting), Blocked = CONST(false));\n+ }\n+\n+ field(7; \"Balancing Account Type\"; Enum \"Gen. Journal Account Type\")\n+ {\n+ Caption = 'Balancing Account Type';\n+ }\n+\n+ field(8; \"Balancing Account No.\"; Code[20])\n+ {\n+ Caption = 'Balancing Account No.';\n+ TableRelation = IF (\"Balancing Account Type\" = CONST(\"G/L Account\")) \"G/L Account\" WHERE(\"Account Type\" = CONST(Posting), Blocked = CONST(false))\n+ ELSE IF (\"Balancing Account Type\" = CONST(\"Bank Account\")) \"Bank Account\" WHERE(Blocked = CONST(false));\n+ }\n+\n+ field(9; \"Payment Terms Code\"; Code[10])\n+ {\n+ Caption = 'Payment Terms Code';\n+ TableRelation = \"Payment Terms\";\n+ }\n+\n+ field(10; \"Auto-Approve Amount\"; Decimal)\n+ {\n+ Caption = 'Auto-Approve Amount';\n+ DecimalPlaces = 2 : 5;\n+ MinValue = 0;\n+ }\n+\n+ field(11; Blocked; Boolean)\n+ {\n+ Caption = 'Blocked';\n+ }\n+\n+ field(12; \"Last Date Modified\"; Date)\n+ {\n+ Caption = 'Last Date Modified';\n+ Editable = false;\n+ }\n+\n+ field(13; \"Expense Report Type\"; Option)\n+ {\n+ Caption = 'Expense Report Type';\n+ OptionCaption = 'All,Employee,Corporate';\n+ OptionMembers = All,Employee,Corporate;\n+ }\n+\n+ field(14; \"Integration Enabled\"; Boolean)\n+ {\n+ Caption = 'Integration Enabled';\n+ }\n+\n+ field(15; \"External System ID\"; Text[50])\n+ {\n+ Caption = 'External System ID';\n+ }\n+\n+ field(16; \"Card Network\"; Code[20])\n+ {\n+ Caption = 'Card Network';\n+ TableRelation = \"Card Network\";\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Code\")\n+ {\n+ Clustered = true;\n+ }\n+\n+ key(Key2; Description)\n+ {\n+ }\n+\n+ key(Key3; \"Expense Report Type\", Blocked)\n+ {\n+ }\n+ }\n+\n+ fieldgroups\n+ {\n+ fieldgroup(DropDown; \"Code\", Description, \"Reimbursable\", \"Corporate Card\")\n+ {\n+ }\n+\n+ fieldgroup(Brick; \"Code\", Description, \"Reimbursable\", Blocked)\n+ {\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ \"Last Date Modified\" := Today;\n+ end;\n+\n+ trigger OnModify()\n+ begin\n+ \"Last Date Modified\" := Today;\n+ end;\n+\n+ trigger OnDelete()\n+ var\n+ ExpenseReportLine: Record \"Expense Report Line\";\n+ PostedExpenseReportLine: Record \"Posted Expense Report Line\";\n+ begin\n+ ExpenseReportLine.SetRange(\"Payment Method Code\", Code);\n+ if not ExpenseReportLine.IsEmpty() then\n+ Error('Cannot delete payment method %1 because it is used in expense report lines.', Code);\n+\n+ PostedExpenseReportLine.SetRange(\"Payment Method Code\", Code);\n+ if not PostedExpenseReportLine.IsEmpty() then\n+ Error('Cannot delete payment method %1 because it is used in posted expense report lines.', Code);\n+ end;\n+\n+ procedure ValidateBalancingAccount()\n+ begin\n+ if (\"Balancing Account Type\" = \"Balancing Account Type\"::\"G/L Account\") and (\"Balancing Account No.\" <> '') then\n+ TestField(\"Default G/L Account\");\n+ end;\n+\n+ procedure IsReimbursementRequired(): Boolean\n+ begin\n+ exit(\"Reimbursable\" and not \"Corporate Card\");\n+ end;\n+}\n--- src/LogiqUpgrade.Codeunit.al\n+++ src/LogiqUpgrade.Codeunit.al\n+/// \n+/// Logiq Upgrade Codeunit (6195)\n+/// Handles upgrade procedures for Logiq EDocument connector\n+/// \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+ trigger OnUpgradePerCompany()\n+ begin\n+ // This is the bad pattern - empty trigger after removing CLEAN26 code block\n+ // Previously had upgrade logic but was removed, leaving empty trigger\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+ LogiqConnection: Record \"Logiq Connection\";\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+\n+ if not LogiqConnection.Get() then begin\n+ LogiqConnection.Init();\n+ LogiqConnection.\"Environment Type\" := LogiqConnection.\"Environment Type\"::Production;\n+ LogiqConnection.\"Authentication Method\" := LogiqConnection.\"Authentication Method\"::Token;\n+ LogiqConnection.\"Request Timeout\" := 60;\n+ LogiqConnection.\"Max Retry Attempts\" := 3;\n+ LogiqConnection.Insert();\n+ end;\n+ end;\n+\n+ procedure UpgradeLogiqDocumentMappings()\n+ var\n+ EDocumentFormat: Record \"E-Document Format\";\n+ LogiqDocumentMapping: Record \"Logiq Document Mapping\";\n+ begin\n+ EDocumentFormat.SetRange(\"Codeunit ID\", Codeunit::\"Logiq Format\");\n+ if EDocumentFormat.FindSet() then\n+ repeat\n+ if not LogiqDocumentMapping.Get(EDocumentFormat.Code) then begin\n+ LogiqDocumentMapping.Init();\n+ LogiqDocumentMapping.\"Format Code\" := EDocumentFormat.Code;\n+ LogiqDocumentMapping.Description := EDocumentFormat.Description;\n+ LogiqDocumentMapping.\"Mapping Type\" := LogiqDocumentMapping.\"Mapping Type\"::Standard;\n+ LogiqDocumentMapping.Enabled := true;\n+ LogiqDocumentMapping.Insert();\n+ end;\n+ until EDocumentFormat.Next() = 0;\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('Logiq connection setup is missing. Please configure the Logiq connection.');\n+\n+ if not EDocServiceConnection.Get('LOGIQ') then\n+ Error('Logiq service connection is not configured.');\n+ end;\n+}\n--- src/TaxTransactionValue.Table.al\n+++ src/TaxTransactionValue.Table.al\n+/// \n+/// Tax Transaction Value Table (18221)\n+/// Stores tax transaction values and calculations\n+/// Field type change requires upgrade code\n+/// \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+ // This field type change from Integer to BigInteger needs upgrade code\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; \"Line No\"; Integer)\n+ {\n+ Caption = 'Line 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 ValidateTransactionValue()\n+ var\n+ TaxType: Record \"Tax Type\";\n+ begin\n+ if \"Tax Type\" <> '' then begin\n+ TaxType.Get(\"Tax Type\");\n+ if not TaxType.Enabled then\n+ Error('Tax Type %1 is not enabled.', \"Tax Type\");\n+ end;\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+}\n--- src/Upgrade.Codeunit.al\n+++ src/Upgrade.Codeunit.al\n+/// \n+/// EDocument Pagero Upgrade Codeunit (6171)\n+/// Handles upgrade procedures for Pagero connector\n+/// \n+codeunit 6171 \"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+ // Setup default connection parameters\n+ SetupDefaultPageroConnection();\n+\n+ UpgradeTag.SetUpgradeTag(PageroUpgradeTags.GetPageroConnectionSetupUpgradeTag());\n+ end;\n+\n+ trigger OnUpgradePerCompany()\n+ begin\n+ // This is the bad pattern - empty trigger body after code removal\n+ // Previously contained upgrade logic but now empty\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+ PageroConnection: Record \"Pagero Connection\";\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+\n+ if not PageroConnection.Get() then begin\n+ PageroConnection.Init();\n+ PageroConnection.\"Environment Type\" := PageroConnection.\"Environment Type\"::Sandbox;\n+ PageroConnection.\"Authentication Type\" := PageroConnection.\"Authentication Type\"::\"Client Credentials\";\n+ PageroConnection.\"Timeout (seconds)\" := 30;\n+ PageroConnection.Insert();\n+ end;\n+ end;\n+\n+ procedure UpgradePageroDocumentLayouts()\n+ var\n+ EDocumentServiceStatus: Record \"E-Document Service Status\";\n+ PageroDocumentLayout: Record \"Pagero Document Layout\";\n+ begin\n+ // Upgrade document layout mappings for new Pagero formats\n+ EDocumentServiceStatus.SetRange(\"E-Document Service Code\", 'PAGERO');\n+ if EDocumentServiceStatus.FindSet() then\n+ repeat\n+ if not PageroDocumentLayout.Get(EDocumentServiceStatus.\"Document Type\", 'PEPPOL_BIS3') then begin\n+ PageroDocumentLayout.Init();\n+ PageroDocumentLayout.\"Document Type\" := EDocumentServiceStatus.\"Document Type\";\n+ PageroDocumentLayout.\"Layout Code\" := 'PEPPOL_BIS3';\n+ PageroDocumentLayout.Description := 'PEPPOL BIS3 Format';\n+ PageroDocumentLayout.Enabled := true;\n+ PageroDocumentLayout.Insert();\n+ end;\n+ until EDocumentServiceStatus.Next() = 0;\n+ end;\n+}\n--- src/UpgradeExpenseAgentSetup.Codeunit.al\n+++ src/UpgradeExpenseAgentSetup.Codeunit.al\n+/// \n+/// Upgrade Expense Agent Setup Codeunit (69135)\n+/// Handles upgrade procedures for Expense Agent setup\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+ // This is the bad pattern - no upgrade tag registration\n+ InstallExpenseAgentSetup.RegisterCapability(); // Direct call without upgrade tag\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+ // 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.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+ var\n+ ExpenseAgentUpgradeTags: Codeunit \"Expense Agent Upgrade Tags\";\n+ begin\n+ // Missing upgrade tag registration for OnUpgradePerDatabase - this is the bad pattern\n+ // Should register the upgrade tag here but it's commented out\n+ // PerDatabaseUpgradeTags.Add(ExpenseAgentUpgradeTags.GetExpenseCapabilityUpgradeTag());\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+ end;\n+ ExpenseCategory.Modify();\n+ until ExpenseCategory.Next() = 0;\n+ end;\n+}", "expected_comments": [{"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 14, "line_end": 14, "body": "OnUpgradePerDatabase calls RegisterCapability without upgrade tag guard \u2014 See agent comment for details.", "severity": "critical"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 52, "line_end": 52, "body": "RegisterPerDatabaseTags subscriber body is empty so per-database upgrade tag is never registered \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 17, "line_end": 17, "body": "OnUpgradePerCompany contains direct implementation instead of delegating to a named local procedure \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/LogiqUpgrade.Codeunit.al", "line_start": 9, "line_end": 9, "body": "OnUpgradePerDatabase contains direct implementation instead of delegating to a single local procedure \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/TaxTransactionValue.Table.al", "line_start": 110, "line_end": 110, "body": "Field type change from Integer to BigInteger requires upgrade code \u2014 See agent comment for details.", "severity": "critical"}, {"file": "src/Upgrade.Codeunit.al", "line_start": 9, "line_end": 9, "body": "OnUpgradePerDatabase contains direct implementation instead of delegating to a single local procedure \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/LogiqUpgrade.Codeunit.al", "line_start": 69, "line_end": 69, "body": "Public upgrade procedure UpgradeLogiqDocumentMappings without upgrade tag guard \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/Upgrade.Codeunit.al", "line_start": 69, "line_end": 69, "body": "Public upgrade procedure UpgradePageroDocumentLayouts without upgrade tag guard \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 58, "line_end": 58, "body": "Public upgrade procedure UpgradeExpenseCategories without upgrade tag guard \u2014 See agent comment for details.", "severity": "medium"}], "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": "upgrade-007", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "--- src/ColumnHeaderDateType.Enum.al\n+++ src/ColumnHeaderDateType.Enum.al\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\" // Changed from ID 5002000 to 764 - breaking change\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+}\n--- src/ExpenseAgentSetup.Table.al\n+++ src/ExpenseAgentSetup.Table.al\n+/// \n+/// Expense Agent Setup Table (69130)\n+/// Configuration and setup parameters for expense agent functionality\n+/// \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+ }\n+\n+ field(11; \"AI Service URL\"; Text[250])\n+ {\n+ Caption = 'AI Service URL';\n+ }\n+\n+ field(12; \"AI Service Key\"; Text[100])\n+ {\n+ Caption = 'AI Service Key';\n+ ExtendedDatatype = Masked;\n+ }\n+\n+ field(13; \"Max File Size (MB)\"; Integer)\n+ {\n+ Caption = 'Max File Size (MB)';\n+ MinValue = 1;\n+ MaxValue = 100;\n+ }\n+\n+ field(14; \"Supported File Types\"; Text[250])\n+ {\n+ Caption = 'Supported File Types';\n+ }\n+\n+ field(15; \"Auto-Submit Threshold\"; Decimal)\n+ {\n+ Caption = 'Auto-Submit Threshold';\n+ DecimalPlaces = 2 : 5;\n+ MinValue = 0;\n+ }\n+\n+ field(16; \"Approval Workflow Enabled\"; Boolean)\n+ {\n+ Caption = 'Approval Workflow Enabled';\n+ }\n+\n+ field(17; \"Default Expense Category\"; Code[20])\n+ {\n+ Caption = 'Default Expense Category';\n+ TableRelation = \"Expense Category\";\n+ }\n+\n+ field(18; \"Receipt Required for Amount\"; Decimal)\n+ {\n+ Caption = 'Receipt Required for Amount';\n+ DecimalPlaces = 2 : 5;\n+ MinValue = 0;\n+ }\n+\n+ field(19; \"Mileage Rate per KM\"; Decimal)\n+ {\n+ Caption = 'Mileage Rate per KM';\n+ DecimalPlaces = 2 : 5;\n+ MinValue = 0;\n+ }\n+\n+ field(20; \"Currency Code\"; Code[10])\n+ {\n+ Caption = 'Currency Code';\n+ TableRelation = Currency;\n+ }\n+\n+ field(350; \"Open Report Notification Frequency\"; Enum \"Notification Frequency\")\n+ {\n+ Caption = 'Open Report Notification Frequency';\n+ InitValue = Daily; // This is the bad pattern - new InitValue without upgrade code\n+ }\n+\n+ field(351; \"Enable Email Notifications\"; Boolean)\n+ {\n+ Caption = 'Enable Email Notifications';\n+ }\n+\n+ field(352; \"Notification Template Code\"; Code[20])\n+ {\n+ Caption = 'Notification Template Code';\n+ TableRelation = \"Email Template\";\n+ }\n+\n+ field(353; \"Supervisor Notification Days\"; Integer)\n+ {\n+ Caption = 'Supervisor Notification Days';\n+ MinValue = 1;\n+ MaxValue = 30;\n+ }\n+\n+ field(354; \"Expense Approval Timeout (Days)\"; Integer)\n+ {\n+ Caption = 'Expense Approval Timeout (Days)';\n+ MinValue = 1;\n+ MaxValue = 90;\n+ }\n+\n+ field(355; \"Auto-Archive Days\"; Integer)\n+ {\n+ Caption = 'Auto-Archive Days';\n+ MinValue = 30;\n+ MaxValue = 365;\n+ }\n+\n+ field(356; \"Enable Integration Log\"; Boolean)\n+ {\n+ Caption = 'Enable Integration Log';\n+ }\n+\n+ field(357; \"Log Retention Days\"; Integer)\n+ {\n+ Caption = 'Log Retention Days';\n+ MinValue = 7;\n+ MaxValue = 90;\n+ }\n+\n+ field(358; \"Expense Number Series\"; Code[20])\n+ {\n+ Caption = 'Expense Number Series';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(359; \"Posted Expense Number Series\"; Code[20])\n+ {\n+ Caption = 'Posted Expense Number Series';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(360; \"Enable Mobile App Integration\"; Boolean)\n+ {\n+ Caption = 'Enable Mobile App Integration';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Primary Key\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ SetDefaultValues();\n+ end;\n+\n+ procedure SetDefaultValues()\n+ begin\n+ \"Enable AI Processing\" := true;\n+ \"Max File Size (MB)\" := 10;\n+ \"Supported File Types\" := 'PDF,JPG,PNG,JPEG';\n+ \"Auto-Submit Threshold\" := 100;\n+ \"Receipt Required for Amount\" := 50;\n+ \"Mileage Rate per KM\" := 0.45;\n+ \"Supervisor Notification Days\" := 7;\n+ \"Expense Approval Timeout (Days)\" := 14;\n+ \"Auto-Archive Days\" := 90;\n+ \"Log Retention Days\" := 30;\n+ end;\n+\n+ procedure ValidateAIServiceSetup()\n+ begin\n+ if \"Enable AI Processing\" then begin\n+ TestField(\"AI Service URL\");\n+ TestField(\"AI Service Key\");\n+ end;\n+ end;\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+}\n--- src/PlanningCreateProdOrder.Enum.al\n+++ src/PlanningCreateProdOrder.Enum.al\n+/// \n+/// Planning Create Production Order Enum (99000829)\n+/// Options for creating production orders from planning worksheets\n+/// \n+enum 99000829 \"Planning Create Prod. Order\"\n+{\n+ Extensible = true;\n+\n+ value(0; \"Planned\")\n+ {\n+ Caption = 'Planned';\n+ }\n+\n+ value(1; \"Firm Planned\")\n+ {\n+ Caption = 'Firm Planned';\n+ }\n+\n+ value(2; \"Firm Planned & Print\")\n+ {\n+ Caption = 'Firm Planned & Print';\n+ }\n+\n+ value(3; Copy)\n+ {\n+ Caption = 'Copy';\n+ }\n+\n+ value(4; \"Copy & Print\")\n+ {\n+ Caption = 'Copy & Print';\n+ }\n+\n+ // These are the new enum values that may require upgrade code\n+ value(5; Released)\n+ {\n+ Caption = 'Released';\n+ }\n+\n+ value(6; \"Released & Print\")\n+ {\n+ Caption = 'Released & Print';\n+ }\n+}\n--- src/ReportSelectionUsage.Enum.al\n+++ src/ReportSelectionUsage.Enum.al\n+/// \n+/// Report Selection Usage Enum (60)\n+/// Defines the different usage types for report selections\n+/// \n+enum 60 \"Report Selection Usage\"\n+{\n+ Extensible = true;\n+\n+ value(0; \"S.Quote\")\n+ {\n+ Caption = 'Sales Quote';\n+ }\n+\n+ value(1; \"S.Order\")\n+ {\n+ Caption = 'Sales Order';\n+ }\n+\n+ value(2; \"S.Invoice\")\n+ {\n+ Caption = 'Sales Invoice';\n+ }\n+\n+ value(3; \"S.Cr.Memo\")\n+ {\n+ Caption = 'Sales Credit Memo';\n+ }\n+\n+ value(4; \"P.Quote\")\n+ {\n+ Caption = 'Purchase Quote';\n+ }\n+\n+ value(5; \"P.Order\")\n+ {\n+ Caption = 'Purchase Order';\n+ }\n+\n+ value(6; \"P.Invoice\")\n+ {\n+ Caption = 'Purchase Invoice';\n+ }\n+\n+ value(7; \"P.Cr.Memo\")\n+ {\n+ Caption = 'Purchase Credit Memo';\n+ }\n+\n+ value(8; \"B.Stmt\")\n+ {\n+ Caption = 'Bank Statement';\n+ }\n+\n+ value(9; \"B.Recon.Test\")\n+ {\n+ Caption = 'Bank Reconciliation Test';\n+ }\n+\n+ value(10; \"B.Check\")\n+ {\n+ Caption = 'Bank Check';\n+ }\n+\n+ value(11; Reminder)\n+ {\n+ Caption = 'Reminder';\n+ }\n+\n+ value(12; \"Fin.Charge\")\n+ {\n+ Caption = 'Finance Charge';\n+ }\n+\n+ value(13; \"Rem.Test\")\n+ {\n+ Caption = 'Reminder Test';\n+ }\n+\n+ value(14; \"F.C.Test\")\n+ {\n+ Caption = 'Finance Charge Test';\n+ }\n+\n+ value(15; \"Prod.Order\")\n+ {\n+ Caption = 'Production Order';\n+ }\n+\n+ value(16; \"S.Blanket\")\n+ {\n+ Caption = 'Sales Blanket Order';\n+ }\n+\n+ value(17; \"P.Blanket\")\n+ {\n+ Caption = 'Purchase Blanket Order';\n+ }\n+\n+ value(18; \"M1\")\n+ {\n+ Caption = 'Sales Document - Test';\n+ }\n+\n+ value(19; \"M2\")\n+ {\n+ Caption = 'Purchase Document - Test';\n+ }\n+\n+ value(150; \"P.Self Billing Invoice\") // This is the bad pattern - new enum value without upgrade code\n+ {\n+ Caption = 'Purchase Self Billing Invoice';\n+ }\n+}\n--- src/ShowCurrencyGenLedgSetup.TableExt.al\n+++ src/ShowCurrencyGenLedgSetup.TableExt.al\n+/// \n+/// Show Currency in General Ledger Setup Table Extension\n+/// Extends General Ledger Setup with currency display options\n+/// \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+ }\n+\n+ field(50201; \"Currency Symbol Position\"; Enum \"Currency Symbol Position\")\n+ {\n+ Caption = 'Currency Symbol Position';\n+ InitValue = \"Before Amount\"; // This is the bad pattern - InitValue with enum re-numbering\n+ }\n+\n+ field(50202; \"Show Currency Symbol\"; Boolean)\n+ {\n+ Caption = 'Show Currency Symbol';\n+ }\n+\n+ field(50203; \"Currency Decimal Places\"; Integer)\n+ {\n+ Caption = 'Currency Decimal Places';\n+ InitValue = 2;\n+ MinValue = 0;\n+ MaxValue = 5;\n+ }\n+\n+ field(50204; \"Use System Currency Format\"; Boolean)\n+ {\n+ Caption = 'Use System Currency Format';\n+ }\n+\n+ field(50205; \"Default Currency Code\"; Code[10])\n+ {\n+ Caption = 'Default Currency Code';\n+ TableRelation = Currency;\n+ }\n+ }\n+\n+ procedure SetShowCurrencySymbolPosition()\n+ var\n+ GeneralLedgerSetup: Record \"General Ledger Setup\";\n+ begin\n+ // This upgrade code only handles 'Default' but enum values were re-numbered\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+ procedure UpdateCurrencyDisplayFormat()\n+ var\n+ Currency: Record Currency;\n+ CurrencyFormat: Text;\n+ begin\n+ Currency.SetFilter(Code, '<>%1', '');\n+ if Currency.FindSet() then\n+ repeat\n+ CurrencyFormat := GetCurrencyDisplayFormat(Currency.Code);\n+ // Update currency display format based on settings\n+ if CurrencyFormat <> '' then begin\n+ Currency.\"Currency Display Format\" := CurrencyFormat;\n+ Currency.Modify();\n+ end;\n+ until Currency.Next() = 0;\n+ end;\n+\n+ local procedure GetCurrencyDisplayFormat(CurrencyCode: Code[10]): Text\n+ var\n+ Currency: Record Currency;\n+ DisplayFormat: Text;\n+ begin\n+ if not Currency.Get(CurrencyCode) then\n+ exit('');\n+\n+ case \"Currency Symbol Position\" of\n+ \"Currency Symbol Position\"::\"Before Amount\":\n+ DisplayFormat := Currency.Symbol + ' #,##0.00';\n+ \"Currency Symbol Position\"::\"After Amount\":\n+ DisplayFormat := '#,##0.00 ' + Currency.Symbol;\n+ \"Currency Symbol Position\"::\"Before Amount with Space\":\n+ DisplayFormat := Currency.Symbol + ' #,##0.00';\n+ \"Currency Symbol Position\"::\"After Amount with Space\":\n+ DisplayFormat := '#,##0.00 ' + Currency.Symbol;\n+ else\n+ DisplayFormat := '#,##0.00';\n+ end;\n+\n+ exit(DisplayFormat);\n+ end;\n+\n+ procedure ValidateCurrencySettings()\n+ begin\n+ if \"Show Currency Symbol\" then\n+ TestField(\"Currency Symbol Position\");\n+\n+ if \"Default Currency Code\" <> '' then begin\n+ if not \"Show Currency Code\" then\n+ Message('Consider enabling Show Currency Code when a default currency is specified.');\n+ end;\n+ end;\n+}", "expected_comments": [{"file": "src/ColumnHeaderDateType.Enum.al", "line_start": 5, "line_end": 5, "body": "Enum ID changed from 5002000 to 764 \u2014 See agent comment for details.", "severity": "critical"}, {"file": "src/ExpenseAgentSetup.Table.al", "line_start": 88, "line_end": 88, "body": "InitValue = Daily added without upgrade code \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ShowCurrencyGenLedgSetup.TableExt.al", "line_start": 17, "line_end": 17, "body": "InitValue with enum re-numbering issue \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/ShowCurrencyGenLedgSetup.TableExt.al", "line_start": 28, "line_end": 28, "body": "InitValue = 2 on Currency Decimal Places without upgrade code for existing record \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ShowCurrencyGenLedgSetup.TableExt.al", "line_start": 50, "line_end": 50, "body": "SetShowCurrencySymbolPosition not in upgrade codeunit, no tag guard, unprotected Get() \u2014 See agent comment for details.", "severity": "high"}], "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": "upgrade-008", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "--- src/OIOUBLInitialize.Codeunit.al\n+++ src/OIOUBLInitialize.Codeunit.al\n+/// \n+/// OIOUBL Initialize Codeunit (13631)\n+/// Handles initialization and migration of OIOUBL setup\n+/// \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+ // This is the bad pattern - migration code removed\n+ // Previously called CODEUNIT.Run(CODEUNIT::\"OIOUBL-MigrateToExtV2\") for version 0.0.0.0\n+ // Customers upgrading from very old versions will miss this migration\n+\n+ // Only handle newer version upgrades now\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+ // Set up default OIOUBL profile\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+ // Update company information for OIOUBL\n+ if CompanyInformation.Get() then begin\n+ if CompanyInformation.\"Country/Region Code\" = 'DK' then begin\n+ CompanyInformation.\"OIOUBL-Profile Code\" := 'DEFAULT';\n+ CompanyInformation.Modify();\n+ end;\n+ end;\n+ end;\n+\n+ local procedure SetupOIOUBLReportSelections()\n+ var\n+ ReportSelections: Record \"Report Selections\";\n+ OIOUBLManagement: Codeunit \"OIOUBL-Management\";\n+ begin\n+ // Set up default report selections for OIOUBL\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+ // Upgrade OIOUBL settings for v25 compatibility\n+ if OIOUBLProfile.Get() then begin\n+ // Update profile settings for new requirements\n+ OIOUBLProfile.\"Check Item Reference\" := true;\n+ OIOUBLProfile.\"Validate Line Discount\" := true;\n+ OIOUBLProfile.Modify();\n+ end;\n+\n+ // Enable OIOUBL in GL Setup if Danish company\n+ if GLSetup.Get() then begin\n+ if GLSetup.\"Country/Region Code\" = 'DK' then begin\n+ GLSetup.\"OIOUBL Enabled\" := true;\n+ GLSetup.Modify();\n+ end;\n+ end;\n+ end;\n+\n+ procedure ValidateOIOUBLSetup()\n+ var\n+ CompanyInformation: Record \"Company Information\";\n+ OIOUBLProfile: Record \"OIOUBL-Profile\";\n+ begin\n+ if not CompanyInformation.Get() then\n+ Error('Company Information must be set up before using OIOUBL.');\n+\n+ if CompanyInformation.\"Country/Region Code\" <> 'DK' then\n+ Error('OIOUBL is only supported for Danish companies.');\n+\n+ if not OIOUBLProfile.Get(CompanyInformation.\"OIOUBL-Profile Code\") then\n+ Error('OIOUBL Profile %1 does not exist.', CompanyInformation.\"OIOUBL-Profile Code\");\n+\n+ if OIOUBLProfile.\"OIOUBL Path\" = '' then\n+ Error('OIOUBL Path must be specified in the profile.');\n+ end;\n+\n+ procedure SetupOIOUBLNumberSeries()\n+ var\n+ NoSeries: Record \"No. Series\";\n+ NoSeriesLine: Record \"No. Series Line\";\n+ begin\n+ // Create number series for OIOUBL documents\n+ if not NoSeries.Get('OIOUBL-INV') then begin\n+ NoSeries.Init();\n+ NoSeries.Code := 'OIOUBL-INV';\n+ NoSeries.Description := 'OIOUBL Invoice Numbers';\n+ NoSeries.\"Default Nos.\" := true;\n+ NoSeries.Insert();\n+\n+ NoSeriesLine.Init();\n+ NoSeriesLine.\"Series Code\" := 'OIOUBL-INV';\n+ NoSeriesLine.\"Line No.\" := 10000;\n+ NoSeriesLine.\"Starting No.\" := 'INV00001';\n+ NoSeriesLine.\"Ending No.\" := 'INV99999';\n+ NoSeriesLine.\"Increment-by No.\" := 1;\n+ NoSeriesLine.Insert();\n+ end;\n+ end;\n+}\n--- src/TempWithholdingTaxEntry.Table.al\n+++ src/TempWithholdingTaxEntry.Table.al\n+/// \n+/// Temp Withholding Tax Entry Table (28043)\n+/// Temporary table for processing withholding tax calculations\n+/// \n+table 28043 \"Temp Withholding Tax Entry\"\n+{\n+ Caption = 'Temp Withholding Tax Entry';\n+ TableType = Temporary;\n+\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer)\n+ {\n+ Caption = 'Entry No.';\n+ }\n+\n+ field(2; \"Gen. Journal Template Name\"; Code[10])\n+ {\n+ Caption = 'Gen. Journal Template Name';\n+ TableRelation = \"Gen. Journal Template\";\n+ }\n+\n+ field(3; \"Gen. Journal Batch Name\"; Code[10])\n+ {\n+ Caption = 'Gen. Journal Batch Name';\n+ TableRelation = \"Gen. Journal Batch\".Name WHERE(\"Journal Template Name\" = FIELD(\"Gen. Journal Template Name\"));\n+ }\n+\n+ field(4; \"Gen. Journal Line No.\"; Integer)\n+ {\n+ Caption = 'Gen. Journal Line No.';\n+ }\n+\n+ field(5; \"WHT Bus. Posting Group\"; Code[20])\n+ {\n+ Caption = 'WHT Bus. Posting Group';\n+ TableRelation = \"WHT Business Posting Group\";\n+ }\n+\n+ field(6; \"WHT Prod. Posting Group\"; Code[20])\n+ {\n+ Caption = 'WHT Prod. Posting Group';\n+ TableRelation = \"WHT Product Posting Group\";\n+ }\n+\n+ field(7; \"Posting Date\"; Date)\n+ {\n+ Caption = 'Posting Date';\n+ }\n+\n+ field(8; \"Document Type\"; Enum \"Gen. Journal Document Type\")\n+ {\n+ Caption = 'Document Type';\n+ }\n+\n+ field(9; \"Document No.\"; Code[20])\n+ {\n+ Caption = 'Document No.';\n+ }\n+\n+ field(10; \"Account Type\"; Enum \"Gen. Journal Account Type\")\n+ {\n+ Caption = 'Account Type';\n+ }\n+\n+ field(11; \"Account No.\"; Code[20])\n+ {\n+ Caption = 'Account No.';\n+ TableRelation = IF (\"Account Type\" = CONST(\"G/L Account\")) \"G/L Account\"\n+ ELSE IF (\"Account Type\" = CONST(Customer)) Customer\n+ ELSE IF (\"Account Type\" = CONST(Vendor)) Vendor\n+ ELSE IF (\"Account Type\" = CONST(\"Bank Account\")) \"Bank Account\"\n+ ELSE IF (\"Account Type\" = CONST(\"Fixed Asset\")) \"Fixed Asset\";\n+ }\n+\n+ field(12; \"Bal. Account Type\"; Enum \"Gen. Journal Account Type\")\n+ {\n+ Caption = 'Bal. Account Type';\n+ }\n+\n+ field(13; \"Bal. Account No.\"; Code[20])\n+ {\n+ Caption = 'Bal. Account No.';\n+ TableRelation = IF (\"Bal. Account Type\" = CONST(\"G/L Account\")) \"G/L Account\"\n+ ELSE IF (\"Bal. Account Type\" = CONST(Customer)) Customer\n+ ELSE IF (\"Bal. Account Type\" = CONST(Vendor)) Vendor\n+ ELSE IF (\"Bal. Account Type\" = CONST(\"Bank Account\")) \"Bank Account\"\n+ ELSE IF (\"Bal. Account Type\" = CONST(\"Fixed Asset\")) \"Fixed Asset\";\n+ }\n+\n+ field(14; Amount; Decimal)\n+ {\n+ Caption = 'Amount';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(15; \"Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(16; \"WHT %\"; Decimal)\n+ {\n+ Caption = 'WHT %';\n+ DecimalPlaces = 0 : 5;\n+ }\n+\n+ field(17; \"WHT Amount\"; Decimal)\n+ {\n+ Caption = 'WHT Amount';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(18; \"WHT Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'WHT Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(19; \"Currency Code\"; Code[10])\n+ {\n+ Caption = 'Currency Code';\n+ TableRelation = Currency;\n+ }\n+\n+ field(20; \"Currency Factor\"; Decimal)\n+ {\n+ Caption = 'Currency Factor';\n+ DecimalPlaces = 0 : 15;\n+ }\n+\n+ field(305; \"Actual Vendor No.\"; Code[20]) // This is the bad pattern - Pending obsolete without upgrade code\n+ {\n+ Caption = 'Actual Vendor No.';\n+ TableRelation = Vendor;\n+ ObsoleteState = Pending;\n+ ObsoleteReason = 'Use Account No. field instead';\n+ ObsoleteTag = '26.0';\n+ }\n+\n+ field(306; \"WHT Certificate No.\"; Code[20])\n+ {\n+ Caption = 'WHT Certificate No.';\n+ }\n+\n+ field(307; \"External Document No.\"; Code[35])\n+ {\n+ Caption = 'External Document No.';\n+ }\n+\n+ field(308; \"Transaction Type\"; Option)\n+ {\n+ Caption = 'Transaction Type';\n+ OptionCaption = ' ,Purchase,Sale';\n+ OptionMembers = \" \",Purchase,Sale;\n+ }\n+\n+ field(309; \"Applied Document Type\"; Enum \"Gen. Journal Document Type\")\n+ {\n+ Caption = 'Applied Document Type';\n+ }\n+\n+ field(310; \"Applied Document No.\"; Code[20])\n+ {\n+ Caption = 'Applied Document No.';\n+ }\n+\n+ field(311; \"Applies-to ID\"; Code[50])\n+ {\n+ Caption = 'Applies-to ID';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Entry No.\")\n+ {\n+ Clustered = true;\n+ }\n+\n+ key(Key2; \"Gen. Journal Template Name\", \"Gen. Journal Batch Name\", \"Gen. Journal Line No.\")\n+ {\n+ }\n+\n+ key(Key3; \"WHT Bus. Posting Group\", \"WHT Prod. Posting Group\")\n+ {\n+ }\n+\n+ key(Key4; \"Posting Date\", \"Document Type\", \"Document No.\")\n+ {\n+ }\n+ }\n+\n+ procedure CalculateWHTAmount()\n+ begin\n+ \"WHT Amount\" := Round(Amount * \"WHT %\" / 100, 0.01);\n+ \"WHT Amount (LCY)\" := Round(\"Amount (LCY)\" * \"WHT %\" / 100, 0.01);\n+ end;\n+\n+ procedure GetWHTPostingSetup(var WHTPostingSetup: Record \"WHT Posting Setup\"): Boolean\n+ begin\n+ exit(WHTPostingSetup.Get(\"WHT Bus. Posting Group\", \"WHT Prod. Posting Group\"));\n+ end;\n+\n+ procedure ValidateWHTCalculation()\n+ begin\n+ TestField(\"WHT Bus. Posting Group\");\n+ TestField(\"WHT Prod. Posting Group\");\n+\n+ if \"WHT %\" <= 0 then\n+ Error('WHT percentage must be greater than zero.');\n+\n+ if Amount = 0 then\n+ Error('Amount cannot be zero for WHT calculation.');\n+ end;\n+}\n--- src/WHTPurchCrMemoHdr.TableExt.al\n+++ src/WHTPurchCrMemoHdr.TableExt.al\n+/// \n+/// WHT Purchase Credit Memo Header Table Extension\n+/// Extends Purchase Credit Memo Header with withholding tax fields\n+/// \n+tableextension 28045 \"WHT Purch Cr Memo Hdr\" extends \"Purch. Cr. Memo Hdr.\"\n+{\n+ fields\n+ {\n+ field(28040; \"WHT Business Posting Group\"; Code[20])\n+ {\n+ Caption = 'WHT Business Posting Group';\n+ TableRelation = \"WHT Business Posting Group\";\n+ }\n+\n+ field(28041; \"WHT Product Posting Group\"; Code[20])\n+ {\n+ Caption = 'WHT Product Posting Group';\n+ TableRelation = \"WHT Product Posting Group\";\n+ }\n+\n+ field(28042; \"WHT Amount\"; Decimal)\n+ {\n+ Caption = 'WHT Amount';\n+ DecimalPlaces = 2 : 5;\n+ Editable = false;\n+ }\n+\n+ field(28043; \"WHT Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'WHT Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ Editable = false;\n+ }\n+\n+ field(28044; \"Total WHT Amount\"; Decimal)\n+ {\n+ Caption = 'Total WHT Amount';\n+ DecimalPlaces = 2 : 5;\n+ Editable = false;\n+ }\n+\n+ field(28045; \"Total WHT Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'Total WHT Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ Editable = false;\n+ }\n+\n+ field(28046; \"WHT Certificate No.\"; Code[20])\n+ {\n+ Caption = 'WHT Certificate No.';\n+ }\n+\n+ field(28047; \"WHT Report Line No.\"; Integer)\n+ {\n+ Caption = 'WHT Report Line No.';\n+ }\n+\n+ field(28048; \"WHT Settled\"; Boolean)\n+ {\n+ Caption = 'WHT Settled';\n+ Editable = false;\n+ }\n+\n+ field(28049; \"WHT Settlement Date\"; Date)\n+ {\n+ Caption = 'WHT Settlement Date';\n+ Editable = false;\n+ }\n+\n+ field(28050; \"WHT Registration No.\"; Text[20])\n+ {\n+ Caption = 'WHT Registration No.';\n+ }\n+\n+ field(28051; \"Withholding Tax Type\"; Code[10])\n+ {\n+ Caption = 'Withholding Tax Type';\n+ TableRelation = \"WHT Revenue Types\";\n+ }\n+\n+ field(28052; \"WHT Revenue Type\"; Code[10])\n+ {\n+ Caption = 'WHT Revenue Type';\n+ TableRelation = \"WHT Revenue Types\";\n+ }\n+\n+ field(28053; \"Actual Vendor No.\"; Code[20]) // This is the bad pattern - Pending obsolete without upgrade code\n+ {\n+ Caption = 'Actual Vendor No.';\n+ TableRelation = Vendor;\n+ ObsoleteState = Pending;\n+ ObsoleteReason = 'Use Buy-from Vendor No. field instead';\n+ ObsoleteTag = '26.0';\n+ }\n+ }\n+\n+ procedure UpdateWHTAmounts()\n+ var\n+ PurchCrMemoLine: Record \"Purch. Cr. Memo Line\";\n+ TotalWHTAmount: Decimal;\n+ TotalWHTAmountLCY: Decimal;\n+ begin\n+ PurchCrMemoLine.SetRange(\"Document No.\", \"No.\");\n+ PurchCrMemoLine.SetFilter(\"WHT Amount\", '<>0');\n+ if PurchCrMemoLine.FindSet() then\n+ repeat\n+ TotalWHTAmount += PurchCrMemoLine.\"WHT Amount\";\n+ TotalWHTAmountLCY += PurchCrMemoLine.\"WHT Amount (LCY)\";\n+ until PurchCrMemoLine.Next() = 0;\n+\n+ \"Total WHT Amount\" := TotalWHTAmount;\n+ \"Total WHT Amount (LCY)\" := TotalWHTAmountLCY;\n+ Modify();\n+ end;\n+\n+ procedure SettleWHT()\n+ var\n+ WHTEntry: Record \"WHT Entry\";\n+ WHTSettlement: Codeunit \"WHT Settlement\";\n+ begin\n+ WHTEntry.SetCurrentKey(\"Document Type\", \"Document No.\");\n+ WHTEntry.SetRange(\"Document Type\", WHTEntry.\"Document Type\"::\"Credit Memo\");\n+ WHTEntry.SetRange(\"Document No.\", \"No.\");\n+ WHTEntry.SetRange(Settled, false);\n+\n+ if WHTEntry.FindSet() then begin\n+ WHTSettlement.SettleWHT(WHTEntry);\n+ \"WHT Settled\" := true;\n+ \"WHT Settlement Date\" := WorkDate();\n+ Modify();\n+ end;\n+ end;\n+\n+ procedure ValidateWHTSetup()\n+ var\n+ WHTPostingSetup: Record \"WHT Posting Setup\";\n+ begin\n+ if \"WHT Business Posting Group\" <> '' then begin\n+ TestField(\"WHT Product Posting Group\");\n+ if not WHTPostingSetup.Get(\"WHT Business Posting Group\", \"WHT Product Posting Group\") then\n+ Error('WHT Posting Setup does not exist for %1, %2', \"WHT Business Posting Group\", \"WHT Product Posting Group\");\n+ end;\n+ end;\n+\n+ procedure GetWHTCertificateAmount(): Decimal\n+ var\n+ WHTCertificate: Record \"WHT Certificate\";\n+ begin\n+ if \"WHT Certificate No.\" = '' then\n+ exit(0);\n+\n+ if WHTCertificate.Get(\"WHT Certificate No.\") then\n+ exit(WHTCertificate.\"WHT Amount\");\n+\n+ exit(0);\n+ end;\n+}\n--- src/WHTPurchTaxCrMemoHdr.Table.al\n+++ src/WHTPurchTaxCrMemoHdr.Table.al\n+/// \n+/// WHT Purchase Tax Credit Memo Header Table (28047)\n+/// Table marked obsolete without corresponding upgrade code\n+/// \n+table 28047 \"WHT Purch. Tax Cr. Memo Hdr.\" // This is the bad pattern - table obsolete without upgrade code\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+ procedure ValidateWHTSetup()\n+ var\n+ WHTPostingSetup: Record \"WHT Posting Setup\";\n+ begin\n+ TestField(\"WHT Business Posting Group\");\n+ TestField(\"WHT Product Posting Group\");\n+\n+ if not WHTPostingSetup.Get(\"WHT Business Posting Group\", \"WHT Product Posting Group\") then\n+ Error('WHT Posting Setup does not exist for %1, %2', \"WHT Business Posting Group\", \"WHT Product Posting Group\");\n+\n+ \"WHT %\" := WHTPostingSetup.\"WHT %\";\n+ end;\n+}\n--- src/WithholdingPurchInvHeader.TableExt.al\n+++ src/WithholdingPurchInvHeader.TableExt.al\n+/// \n+/// Withholding Purchase Invoice Header Table Extension\n+/// Extends Purchase Invoice Header with withholding tax fields\n+/// \n+tableextension 28046 \"Withholding Purch Inv Header\" extends \"Purch. Inv. Header\"\n+{\n+ fields\n+ {\n+ field(28040; \"WHT Business Posting Group\"; Code[20])\n+ {\n+ Caption = 'WHT Business Posting Group';\n+ TableRelation = \"WHT Business Posting Group\";\n+ }\n+\n+ field(28041; \"WHT Product Posting Group\"; Code[20])\n+ {\n+ Caption = 'WHT Product Posting Group';\n+ TableRelation = \"WHT Product Posting Group\";\n+ }\n+\n+ field(28042; \"WHT Amount\"; Decimal)\n+ {\n+ Caption = 'WHT Amount';\n+ DecimalPlaces = 2 : 5;\n+ Editable = false;\n+ }\n+\n+ field(28043; \"WHT Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'WHT Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ Editable = false;\n+ }\n+\n+ field(28044; \"Total WHT Amount\"; Decimal)\n+ {\n+ Caption = 'Total WHT Amount';\n+ DecimalPlaces = 2 : 5;\n+ Editable = false;\n+ }\n+\n+ field(28045; \"Total WHT Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'Total WHT Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ Editable = false;\n+ }\n+\n+ field(28046; \"WHT Certificate No.\"; Code[20])\n+ {\n+ Caption = 'WHT Certificate No.';\n+ }\n+\n+ field(28047; \"WHT Report Line No.\"; Integer)\n+ {\n+ Caption = 'WHT Report Line No.';\n+ }\n+\n+ field(28048; \"WHT Settled\"; Boolean)\n+ {\n+ Caption = 'WHT Settled';\n+ Editable = false;\n+ }\n+\n+ field(28049; \"WHT Settlement Date\"; Date)\n+ {\n+ Caption = 'WHT Settlement Date';\n+ Editable = false;\n+ }\n+\n+ field(28050; \"WHT Registration No.\"; Text[20])\n+ {\n+ Caption = 'WHT Registration No.';\n+ }\n+\n+ field(28051; \"Withholding Tax Type\"; Code[10])\n+ {\n+ Caption = 'Withholding Tax Type';\n+ TableRelation = \"WHT Revenue Types\";\n+ }\n+\n+ field(28052; \"WHT Revenue Type\"; Code[10])\n+ {\n+ Caption = 'WHT Revenue Type';\n+ TableRelation = \"WHT Revenue Types\";\n+ }\n+\n+ field(28053; \"Actual Vendor No.\"; Code[20]) // This is the bad pattern - Pending obsolete without upgrade code\n+ {\n+ Caption = 'Actual Vendor No.';\n+ TableRelation = Vendor;\n+ ObsoleteState = Pending;\n+ ObsoleteReason = 'Use Buy-from Vendor No. field instead';\n+ ObsoleteTag = '26.0';\n+ }\n+ }\n+\n+ procedure CalculateTotalWHTAmount()\n+ var\n+ PurchInvLine: Record \"Purch. Inv. Line\";\n+ TotalWHTAmount: Decimal;\n+ TotalWHTAmountLCY: Decimal;\n+ begin\n+ PurchInvLine.SetRange(\"Document No.\", \"No.\");\n+ PurchInvLine.SetFilter(\"WHT Amount\", '<>0');\n+ if PurchInvLine.FindSet() then\n+ repeat\n+ TotalWHTAmount += PurchInvLine.\"WHT Amount\";\n+ TotalWHTAmountLCY += PurchInvLine.\"WHT Amount (LCY)\";\n+ until PurchInvLine.Next() = 0;\n+\n+ \"Total WHT Amount\" := TotalWHTAmount;\n+ \"Total WHT Amount (LCY)\" := TotalWHTAmountLCY;\n+ Modify();\n+ end;\n+\n+ procedure PrintWHTCertificate()\n+ var\n+ WHTCertificate: Record \"WHT Certificate\";\n+ WHTCertificateReport: Report \"WHT Certificate\";\n+ begin\n+ if \"WHT Certificate No.\" = '' then\n+ Error('WHT Certificate No. must be specified to print certificate.');\n+\n+ WHTCertificate.SetRange(\"Certificate No.\", \"WHT Certificate No.\");\n+ WHTCertificateReport.SetTableView(WHTCertificate);\n+ WHTCertificateReport.Run();\n+ end;\n+\n+ procedure ValidateWHTPostingGroups()\n+ var\n+ WHTPostingSetup: Record \"WHT Posting Setup\";\n+ begin\n+ if (\"WHT Business Posting Group\" <> '') or (\"WHT Product Posting Group\" <> '') then begin\n+ TestField(\"WHT Business Posting Group\");\n+ TestField(\"WHT Product Posting Group\");\n+\n+ if not WHTPostingSetup.Get(\"WHT Business Posting Group\", \"WHT Product Posting Group\") then\n+ Error('WHT Posting Setup %1,%2 does not exist.', \"WHT Business Posting Group\", \"WHT Product Posting Group\");\n+ end;\n+ end;\n+\n+ procedure GetWHTEntries(var WHTEntry: Record \"WHT Entry\")\n+ begin\n+ WHTEntry.SetCurrentKey(\"Document Type\", \"Document No.\");\n+ WHTEntry.SetRange(\"Document Type\", WHTEntry.\"Document Type\"::Invoice);\n+ WHTEntry.SetRange(\"Document No.\", \"No.\");\n+ end;\n+\n+ procedure HasWHTEntries(): Boolean\n+ var\n+ WHTEntry: Record \"WHT Entry\";\n+ begin\n+ GetWHTEntries(WHTEntry);\n+ exit(not WHTEntry.IsEmpty);\n+ end;\n+\n+ procedure GenerateWHTCertificate()\n+ var\n+ WHTCertificate: Record \"WHT Certificate\";\n+ WHTManagement: Codeunit \"WHT Management\";\n+ begin\n+ if \"WHT Certificate No.\" <> '' then\n+ Error('WHT Certificate already exists for this document.');\n+\n+ if \"Total WHT Amount\" = 0 then\n+ Error('Cannot generate WHT Certificate when WHT Amount is zero.');\n+\n+ \"WHT Certificate No.\" := WHTManagement.CreateWHTCertificate(Rec);\n+ Modify();\n+ end;\n+}", "expected_comments": [{"file": "src/OIOUBLInitialize.Codeunit.al", "line_start": 33, "line_end": 33, "body": "Version check pattern instead of upgrade tags - not idempotent \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/OIOUBLInitialize.Codeunit.al", "line_start": 74, "line_end": 74, "body": "UpgradeToV25 procedure lacks upgrade tag to prevent re-execution \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/WHTPurchTaxCrMemoHdr.Table.al", "line_start": 9, "line_end": 9, "body": "Table marked ObsoleteState = Removed without corresponding upgrade code for data migration \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/WHTPurchTaxCrMemoHdr.Table.al", "line_start": 172, "line_end": 172, "body": "OnDelete trigger on removed table can cascade-delete related WHT records during cleanup or migration \u2014 See agent comment for details.", "severity": "medium"}], "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": "upgrade-009", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "--- src/ContactSyncFolder.Table.al\n+++ src/ContactSyncFolder.Table.al\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]) // This is the bad pattern - new field without upgrade code\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+\n+ procedure ValidateSyncConfiguration()\n+ var\n+ ExchangeServiceConnection: Record \"Exchange Service Connection\";\n+ begin\n+ TestField(\"Exchange Service\");\n+\n+ if not ExchangeServiceConnection.Get(\"Exchange Service\") then\n+ Error('Exchange Service Connection %1 does not exist.', \"Exchange Service\");\n+\n+ ExchangeServiceConnection.TestField(Enabled, true);\n+ end;\n+}\n--- src/Currency.Table.al\n+++ src/Currency.Table.al\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\"; // This is the bad pattern - new field without upgrade code\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+ trigger OnDelete()\n+ var\n+ BankAccount: Record \"Bank Account\";\n+ CustLedgerEntry: Record \"Cust. Ledger Entry\";\n+ VendLedgerEntry: Record \"Vendor Ledger Entry\";\n+ begin\n+ BankAccount.SetRange(\"Currency Code\", Code);\n+ if not BankAccount.IsEmpty then\n+ Error('Cannot delete currency %1 because it is used by bank accounts.', Code);\n+\n+ CustLedgerEntry.SetRange(\"Currency Code\", Code);\n+ if not CustLedgerEntry.IsEmpty then\n+ Error('Cannot delete currency %1 because it is used in customer ledger entries.', Code);\n+\n+ VendLedgerEntry.SetRange(\"Currency Code\", Code);\n+ if not VendLedgerEntry.IsEmpty then\n+ Error('Cannot delete currency %1 because it is used in vendor ledger entries.', Code);\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+}\n--- src/PayablesAgentSetup.Table.al\n+++ src/PayablesAgentSetup.Table.al\n+/// \n+/// Payables Agent Setup Table (69140)\n+/// Configuration settings for the Payables Agent functionality\n+/// \n+table 69140 \"Payables Agent Setup\"\n+{\n+ Caption = 'Payables 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+ InitValue = true;\n+ }\n+\n+ field(11; \"AI Service URL\"; Text[250])\n+ {\n+ Caption = 'AI Service URL';\n+ }\n+\n+ field(12; \"AI Service Key\"; Text[100])\n+ {\n+ Caption = 'AI Service Key';\n+ ExtendedDatatype = Masked;\n+ }\n+\n+ field(13; \"Max Document Size (MB)\"; Integer)\n+ {\n+ Caption = 'Max Document Size (MB)';\n+ InitValue = 25;\n+ MinValue = 1;\n+ MaxValue = 100;\n+ }\n+\n+ field(14; \"Supported Document Types\"; Text[250])\n+ {\n+ Caption = 'Supported Document Types';\n+ InitValue = 'PDF,TIF,TIFF,JPG,JPEG,PNG,BMP';\n+ }\n+\n+ field(15; \"Auto-Approval Threshold\"; Decimal)\n+ {\n+ Caption = 'Auto-Approval Threshold';\n+ DecimalPlaces = 2 : 5;\n+ MinValue = 0;\n+ }\n+\n+ field(16; \"Enable Workflow Integration\"; Boolean)\n+ {\n+ Caption = 'Enable Workflow Integration';\n+ InitValue = true;\n+ }\n+\n+ field(17; \"Default Gen. Bus. Posting Group\"; Code[20])\n+ {\n+ Caption = 'Default Gen. Bus. Posting Group';\n+ TableRelation = \"Gen. Business Posting Group\";\n+ }\n+\n+ field(18; \"Default Gen. Prod. Posting Group\"; Code[20])\n+ {\n+ Caption = 'Default Gen. Prod. Posting Group';\n+ TableRelation = \"Gen. Product Posting Group\";\n+ }\n+\n+ field(19; \"Default VAT Bus. Posting Group\"; Code[20])\n+ {\n+ Caption = 'Default VAT Bus. Posting Group';\n+ TableRelation = \"VAT Business Posting Group\";\n+ }\n+\n+ field(20; \"Default VAT Prod. Posting Group\"; Code[20])\n+ {\n+ Caption = 'Default VAT Prod. Posting Group';\n+ TableRelation = \"VAT Product Posting Group\";\n+ }\n+\n+ field(21; \"Invoice Number Series\"; Code[20])\n+ {\n+ Caption = 'Invoice Number Series';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(22; \"Credit Memo Number Series\"; Code[20])\n+ {\n+ Caption = 'Credit Memo Number Series';\n+ TableRelation = \"No. Series\";\n+ }\n+\n+ field(23; \"Journal Template Name\"; Code[10])\n+ {\n+ Caption = 'Journal Template Name';\n+ TableRelation = \"Gen. Journal Template\" WHERE(Type = CONST(Purchases));\n+ }\n+\n+ field(24; \"Journal Batch Name\"; Code[10])\n+ {\n+ Caption = 'Journal Batch Name';\n+ TableRelation = \"Gen. Journal Batch\".Name WHERE(\"Journal Template Name\" = FIELD(\"Journal Template Name\"));\n+ }\n+\n+ field(25; \"Enable OCR Processing\"; Boolean)\n+ {\n+ Caption = 'Enable OCR Processing';\n+ InitValue = true;\n+ }\n+\n+ field(26; \"OCR Service URL\"; Text[250])\n+ {\n+ Caption = 'OCR Service URL';\n+ }\n+\n+ field(27; \"OCR Service Key\"; Text[100])\n+ {\n+ Caption = 'OCR Service Key';\n+ ExtendedDatatype = Masked;\n+ }\n+\n+ field(28; \"Min. Confidence Level\"; Decimal)\n+ {\n+ Caption = 'Min. Confidence Level';\n+ DecimalPlaces = 0 : 2;\n+ InitValue = 0.85;\n+ MinValue = 0.5;\n+ MaxValue = 1.0;\n+ }\n+\n+ field(29; \"Enable Data Validation\"; Boolean)\n+ {\n+ Caption = 'Enable Data Validation';\n+ InitValue = true;\n+ }\n+\n+ field(30; \"Validation Rules\"; Text[500])\n+ {\n+ Caption = 'Validation Rules';\n+ }\n+\n+ field(55; \"Use MLLM Processing\"; Boolean) // This is the bad pattern - Boolean without InitValue\n+ {\n+ Caption = 'Use MLLM Processing';\n+ // Missing InitValue - existing records will default to false\n+ }\n+\n+ field(56; \"MLLM Model Name\"; Text[100])\n+ {\n+ Caption = 'MLLM Model Name';\n+ }\n+\n+ field(57; \"MLLM Service Endpoint\"; Text[250])\n+ {\n+ Caption = 'MLLM Service Endpoint';\n+ }\n+\n+ field(58; \"MLLM API Key\"; Text[100])\n+ {\n+ Caption = 'MLLM API Key';\n+ ExtendedDatatype = Masked;\n+ }\n+\n+ field(59; \"MLLM Temperature\"; Decimal)\n+ {\n+ Caption = 'MLLM Temperature';\n+ DecimalPlaces = 0 : 2;\n+ InitValue = 0.7;\n+ MinValue = 0.0;\n+ MaxValue = 2.0;\n+ }\n+\n+ field(60; \"Enable Archive\"; Boolean)\n+ {\n+ Caption = 'Enable Archive';\n+ InitValue = true;\n+ }\n+\n+ field(61; \"Archive Retention Days\"; Integer)\n+ {\n+ Caption = 'Archive Retention Days';\n+ InitValue = 365;\n+ MinValue = 30;\n+ MaxValue = 2555; // 7 years\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Primary Key\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ SetDefaultValues();\n+ end;\n+\n+ local procedure SetDefaultValues()\n+ begin\n+ \"Enable AI Processing\" := true;\n+ \"Max Document Size (MB)\" := 25;\n+ \"Supported Document Types\" := 'PDF,TIF,TIFF,JPG,JPEG,PNG,BMP';\n+ \"Auto-Approval Threshold\" := 1000;\n+ \"Enable Workflow Integration\" := true;\n+ \"Enable OCR Processing\" := true;\n+ \"Min. Confidence Level\" := 0.85;\n+ \"Enable Data Validation\" := true;\n+ \"MLLM Temperature\" := 0.7;\n+ \"Enable Archive\" := true;\n+ \"Archive Retention Days\" := 365;\n+ end;\n+\n+ procedure ValidateAIServiceSetup()\n+ begin\n+ if \"Enable AI Processing\" then begin\n+ TestField(\"AI Service URL\");\n+ TestField(\"AI Service Key\");\n+ end;\n+\n+ if \"Enable OCR Processing\" then begin\n+ TestField(\"OCR Service URL\");\n+ TestField(\"OCR Service Key\");\n+ end;\n+\n+ if \"Use MLLM Processing\" then begin\n+ TestField(\"MLLM Service Endpoint\");\n+ TestField(\"MLLM API Key\");\n+ TestField(\"MLLM Model Name\");\n+ end;\n+ end;\n+\n+ procedure ValidatePostingSetup()\n+ var\n+ GenBusPostingGroup: Record \"Gen. Business Posting Group\";\n+ GenProdPostingGroup: Record \"Gen. Product Posting Group\";\n+ VATBusPostingGroup: Record \"VAT Business Posting Group\";\n+ VATProdPostingGroup: Record \"VAT Product Posting Group\";\n+ begin\n+ if \"Default Gen. Bus. Posting Group\" <> '' then\n+ GenBusPostingGroup.Get(\"Default Gen. Bus. Posting Group\");\n+\n+ if \"Default Gen. Prod. Posting Group\" <> '' then\n+ GenProdPostingGroup.Get(\"Default Gen. Prod. Posting Group\");\n+\n+ if \"Default VAT Bus. Posting Group\" <> '' then\n+ VATBusPostingGroup.Get(\"Default VAT Bus. Posting Group\");\n+\n+ if \"Default VAT Prod. Posting Group\" <> '' then\n+ VATProdPostingGroup.Get(\"Default VAT Prod. Posting Group\");\n+ end;\n+\n+ procedure GetMLLMSettings(var ModelName: Text[100]; var ServiceEndpoint: Text[250]; var Temperature: Decimal): Boolean\n+ begin\n+ if not \"Use MLLM Processing\" then\n+ exit(false);\n+\n+ ModelName := \"MLLM Model Name\";\n+ ServiceEndpoint := \"MLLM Service Endpoint\";\n+ Temperature := \"MLLM Temperature\";\n+ exit(true);\n+ end;\n+}\n--- src/StandardAccount.Table.al\n+++ src/StandardAccount.Table.al\n+/// \n+/// Standard Account Table (12330)\n+/// Maps standard accounting codes to local G/L accounts\n+/// \n+table 12330 \"Standard Account\"\n+{\n+ Caption = 'Standard Account';\n+ DataClassification = CustomerContent;\n+ LookupPageID = \"Standard Account List\";\n+ DrillDownPageID = \"Standard Account List\";\n+\n+ fields\n+ {\n+ field(1; \"No.\"; Code[20])\n+ {\n+ Caption = 'No.';\n+ NotBlank = true;\n+ }\n+\n+ field(2; Description; Text[100])\n+ {\n+ Caption = 'Description';\n+ }\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), Blocked = CONST(false));\n+ }\n+\n+ field(4; \"Type\"; Option)\n+ {\n+ Caption = 'Type';\n+ OptionCaption = 'Assets,Liabilities,Equity,Revenue,Expense';\n+ OptionMembers = Assets,Liabilities,Equity,Revenue,Expense;\n+ }\n+\n+ field(5; \"Extended No.\"; Code[30]) // This is the bad pattern - new field without upgrade code\n+ {\n+ Caption = 'Extended No.';\n+ }\n+\n+ field(10; \"Country/Region Code\"; Code[10])\n+ {\n+ Caption = 'Country/Region Code';\n+ TableRelation = \"Country/Region\";\n+ }\n+\n+ field(11; \"Standard Account Category\"; Code[20])\n+ {\n+ Caption = 'Standard Account Category';\n+ TableRelation = \"Standard Account Category\";\n+ }\n+\n+ field(12; \"SAF-T Account Code\"; Code[20])\n+ {\n+ Caption = 'SAF-T Account Code';\n+ }\n+\n+ field(13; \"SAF-T Account Type\"; Option)\n+ {\n+ Caption = 'SAF-T Account Type';\n+ OptionCaption = 'General Ledger,Accounts Receivable,Accounts Payable,Fixed Assets,Inventory,Other Assets,Equity,Revenue,Expense,Other';\n+ OptionMembers = \"General Ledger\",\"Accounts Receivable\",\"Accounts Payable\",\"Fixed Assets\",Inventory,\"Other Assets\",Equity,Revenue,Expense,Other;\n+ }\n+\n+ field(14; \"Income Statement\"; Boolean)\n+ {\n+ Caption = 'Income Statement';\n+ }\n+\n+ field(15; \"Balance Sheet\"; Boolean)\n+ {\n+ Caption = 'Balance Sheet';\n+ }\n+\n+ field(16; \"Cash Flow Statement\"; Boolean)\n+ {\n+ Caption = 'Cash Flow Statement';\n+ }\n+\n+ field(17; \"Grouping Code\"; Code[20])\n+ {\n+ Caption = 'Grouping Code';\n+ TableRelation = \"Standard Account Grouping\";\n+ }\n+\n+ field(18; \"Sort Order\"; Integer)\n+ {\n+ Caption = 'Sort Order';\n+ }\n+\n+ field(19; \"Last Date Modified\"; Date)\n+ {\n+ Caption = 'Last Date Modified';\n+ Editable = false;\n+ }\n+\n+ field(20; Blocked; Boolean)\n+ {\n+ Caption = 'Blocked';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"No.\")\n+ {\n+ Clustered = true;\n+ }\n+\n+ key(Key2; \"Country/Region Code\", \"Standard Account Category\")\n+ {\n+ }\n+\n+ key(Key3; \"Type\", \"Sort Order\")\n+ {\n+ }\n+\n+ key(Key4; \"SAF-T Account Type\", \"SAF-T Account Code\")\n+ {\n+ }\n+\n+ key(Key5; \"Extended No.\")\n+ {\n+ }\n+ }\n+\n+ fieldgroups\n+ {\n+ fieldgroup(DropDown; \"No.\", Description, \"G/L Account No.\", \"Type\")\n+ {\n+ }\n+\n+ fieldgroup(Brick; \"No.\", Description, \"Standard Account Category\", Blocked)\n+ {\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ \"Last Date Modified\" := Today;\n+ end;\n+\n+ trigger OnModify()\n+ begin\n+ \"Last Date Modified\" := Today;\n+ end;\n+\n+ trigger OnDelete()\n+ var\n+ StandardAccountMapping: Record \"Standard Account Mapping\";\n+ begin\n+ StandardAccountMapping.SetRange(\"Standard Account No.\", \"No.\");\n+ StandardAccountMapping.DeleteAll();\n+ end;\n+\n+ procedure ValidateGLAccountMapping()\n+ var\n+ GLAccount: Record \"G/L Account\";\n+ begin\n+ if \"G/L Account No.\" <> '' then begin\n+ GLAccount.Get(\"G/L Account No.\");\n+ GLAccount.TestField(\"Account Type\", GLAccount.\"Account Type\"::Posting);\n+ GLAccount.TestField(Blocked, false);\n+ end;\n+ end;\n+\n+ procedure GetStatementAssignment(): Text\n+ var\n+ StatementText: Text;\n+ begin\n+ if \"Income Statement\" then\n+ StatementText += 'Income Statement ';\n+\n+ if \"Balance Sheet\" then\n+ StatementText += 'Balance Sheet ';\n+\n+ if \"Cash Flow Statement\" then\n+ StatementText += 'Cash Flow Statement ';\n+\n+ exit(StatementText.TrimEnd(' '));\n+ end;\n+\n+ procedure SetStatementFlags(AccountType: Option)\n+ begin\n+ case AccountType of\n+ Type::Assets, Type::Liabilities, Type::Equity:\n+ begin\n+ \"Balance Sheet\" := true;\n+ \"Income Statement\" := false;\n+ end;\n+ Type::Revenue, Type::Expense:\n+ begin\n+ \"Income Statement\" := true;\n+ \"Balance Sheet\" := false;\n+ end;\n+ end;\n+ end;\n+\n+ procedure CreateGLAccountMapping()\n+ var\n+ GLAccount: Record \"G/L Account\";\n+ StandardAccountMapping: Record \"Standard Account Mapping\";\n+ begin\n+ if \"G/L Account No.\" = '' then\n+ Error('G/L Account No. must be specified to create mapping.');\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 not StandardAccountMapping.Get(\"No.\", \"G/L Account No.\") then begin\n+ StandardAccountMapping.Init();\n+ StandardAccountMapping.\"Standard Account No.\" := \"No.\";\n+ StandardAccountMapping.\"G/L Account No.\" := \"G/L Account No.\";\n+ StandardAccountMapping.\"Mapping Type\" := StandardAccountMapping.\"Mapping Type\"::Direct;\n+ StandardAccountMapping.Insert();\n+ end;\n+ end;\n+}\n--- src/UpgradeExpenseAgentSetup.Codeunit.al\n+++ src/UpgradeExpenseAgentSetup.Codeunit.al\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+ // This is the bad pattern - direct implementation code in trigger instead of method call\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+}", "expected_comments": [{"file": "src/Currency.Table.al", "line_start": 168, "line_end": 168, "body": "New field with InitValue = Default without upgrade code \u2014 See agent comment for details.", "severity": "medium"}, {"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 \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 17, "line_end": 17, "body": "OnUpgradePerCompany trigger contains inline implementation instead of delegating to a named procedure \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 57, "line_end": 57, "body": "UpgradeExpenseAgentSetup() unconditionally overwrites existing setup values during upgrade \u2014 See agent comment for details.", "severity": "high"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 68, "line_end": 68, "body": "Public upgrade procedure UpgradeExpenseCategories without upgrade tag guard \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 98, "line_end": 98, "body": "Public upgrade procedure UpgradeExpensePaymentMethods without upgrade tag guard \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ContactSyncFolder.Table.al", "line_start": 44, "line_end": 44, "body": "InitValue = true on Sync Enabled without upgrade code for existing records \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/ContactSyncFolder.Table.al", "line_start": 104, "line_end": 104, "body": "Conflict Resolution InitValue on existing table without upgrade code for existing records \u2014 See agent comment for details.", "severity": "medium"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 94, "line_end": 94, "body": "Modify() called unconditionally even when no case branch matched \u2014 See agent comment for details.", "severity": "low"}], "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/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/run_all_codereview_evals.ps1 b/scripts/run_all_codereview_evals.ps1 new file mode 100644 index 000000000..3f12152af --- /dev/null +++ b/scripts/run_all_codereview_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 scripts/run_all_codereview_evals.ps1 +# pwsh scripts/run_all_codereview_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/src/bcbench/agent/claude/agent.py b/src/bcbench/agent/claude/agent.py index b80017c5c..3cf1ec609 100644 --- a/src/bcbench/agent/claude/agent.py +++ b/src/bcbench/agent/claude/agent.py @@ -29,6 +29,11 @@ def run_claude_code( config_file = Path(__file__).parent.parent / "shared" / "config.yaml" claude_config = yaml.safe_load(config_file.read_text()) + is_code_review_bcapps = category == EvaluationCategory.CODE_REVIEW and entry.repo == "microsoft/BCApps" + if is_code_review_bcapps: + claude_config.setdefault("instructions", {})["enabled"] = True + claude_config.setdefault("skills", {})["enabled"] = True + logger.info(f"Running Claude Code on: {entry.instance_id}") prompt: str = build_prompt(entry, repo_path, claude_config, category, al_mcp=al_mcp) diff --git a/src/bcbench/agent/copilot/agent.py b/src/bcbench/agent/copilot/agent.py index 51023745d..400147003 100644 --- a/src/bcbench/agent/copilot/agent.py +++ b/src/bcbench/agent/copilot/agent.py @@ -31,6 +31,11 @@ def run_copilot_agent( config_file = Path(__file__).parent.parent / "shared" / "config.yaml" copilot_config = yaml.safe_load(config_file.read_text()) + is_code_review_bcapps = category == EvaluationCategory.CODE_REVIEW and entry.repo == "microsoft/BCApps" + if is_code_review_bcapps: + copilot_config.setdefault("instructions", {})["enabled"] = True + copilot_config.setdefault("skills", {})["enabled"] = True + logger.info(f"Running GitHub Copilot CLI on: {entry.instance_id}") prompt: str = build_prompt(entry, repo_path, copilot_config, category, al_mcp=al_mcp) diff --git a/src/bcbench/agent/shared/config.yaml b/src/bcbench/agent/shared/config.yaml index 352a0edf2..5a28065b2 100644 --- a/src/bcbench/agent/shared/config.yaml +++ b/src/bcbench/agent/shared/config.yaml @@ -55,12 +55,14 @@ prompt: {% endif %} code-review-template: | - /review Review the unstaged changes in this repository and provide feedback as a code reviewer. + @al-code-review - Output your review as a JSON array of comment objects with the following schema: - [{"file": "path/to/file.al", "line_start": 10, "line_end": 15, "body": "Your review comment", "severity": "one of: suggestion, warning, error"}] + Review the following code patch and return findings using the output schema defined by the al-code-review skill. - Save the JSON array to a file named "review.json" in the current directory. + Code Patch: + {{task}} + + Save the findings JSON to a file named "review.json" in the current directory without remapping fields. # controls: # 1. whether to copy custom instructions from `src/bcbench/agent/shared/instructions//` diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/UI.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/UI.md new file mode 100644 index 000000000..3705f851f --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/UI.md @@ -0,0 +1,445 @@ +You are a UI text reviewer for Microsoft Dynamics 365 Business Central AL applications. +Your focus is on UI text quality, voice consistency, capitalization rules, character length limits, and adherence to Business Central UI text guidelines in AL page files. + +Your task is to perform a **UI text review only** of this AL code change. + +IMPORTANT: This review applies ONLY to page files (files matching the pattern `*.page.al`). +If the file under review is NOT a page file, skip the review entirely and report nothing. + +IMPORTANT GUIDELINES: +- Focus exclusively on identifying problems, risks, and potential issues in UI-facing text +- Do NOT include praise, positive commentary, or statements like "looks good" +- Be constructive and actionable in your feedback +- Provide specific, evidence-based observations +- Categorize issues by severity: Critical, High, Medium, Low +- Only report UI text issues (spelling, grammar, capitalization, length, voice) + +CRITICAL EXCLUSIONS - Do NOT report on: +- Security vulnerabilities (hardcoded credentials, injection risks, secrets) +- Performance issues (inefficient queries, N+1 problems, resource usage) +- Code style, formatting, naming conventions unrelated to UI text +- Business logic errors or functional issues +- Access control or permission issues +- These are handled by dedicated review agents + +CRITICAL SCOPE LIMITATION: +- You MUST ONLY analyze and report issues for lines that have actual changes (marked with + or - in the diff) +- Ignore all context lines (lines without + or - markers) - they are unchanged and not under review +- Do NOT report issues on unchanged lines, even if you notice UI text problems there +- Do NOT infer, assume, or hallucinate what other parts of the file might contain +- Do NOT rewrite or rephrase text unless a language or voice issue is detected +- What the developer delivers is what gets built — only flag genuine issues + +WHAT TO REVIEW: +- Typos, missing spaces, grammar errors, or inappropriate wording +- Correct use of capitalization (sentence-style vs. title-style depending on context) +- Compliance with recommended maximum character lengths to avoid truncation +- Adherence to voice guidelines for specific UI text types (tooltips, teaching tips, tour tips, error messages, checklist content, notifications, etc.) +- Use of "&" instead of "and" +- Use of banned terms: Disabled, Invalid, Whitelist, Blacklist + +WHAT NOT TO DO: +- Do NOT rewrite or rephrase all text — only suggest changes when language or voice issues are detected +- Do NOT alter the intended meaning of text +- Do NOT remove important details from text + +============================================================================= +BC UI NOMENCLATURE (context for understanding page types) +============================================================================= + +ROLE CENTER (RC): +- A specialized page (PageType = RoleCenter) with business content tailored to a user's role. +- Shows headlines, KPI tiles, charts, an Activities section, and action/navigation shortcuts. + +LIST PAGES: +- Present data in a tabular format (e.g., list of customers, vendors, items). +- Columns represent field captions; action bar includes New, Edit, Delete, etc. + +TASK PAGES (Card/Document): +- Focus on viewing or editing details for a specific record (customer, sales order, etc.). +- Organize fields into FastTabs (collapsible sections) with a FactBox pane and action bar. + +DIALOGS: +- Pop-up modal windows for confirmations, warnings, or input prompts. +- Require user response before proceeding. + +NOTIFICATIONS: +- Contextual non-blocking bar at the top of a page with a message and optional action links. + +ERROR MESSAGES: +- Appear as a dialog or pop-over during user interaction when an error condition occurs. +- Should include a title, brief explanatory text, and guidance on resolution when possible. + +============================================================================= +CAPITALIZATION RULES +============================================================================= + +SENTENCE-STYLE CAPITALIZATION: +- Capitalize only the first word and proper nouns. +- Used for: tooltips, dialog text, error messages, notifications, titles (page, section, dialog), text values, and captions that are sentence phrases (imperative or declarative). + +TITLE-STYLE CAPITALIZATION: +- Capitalize the first letter of each major word. +- Used ONLY for captions that are pure noun phrases (no verbs). + +HOW TO DETERMINE CAPTION CAPITALIZATION: +- If the caption is a noun phrase (no verbs) → title-style capitalization. +- If the caption is a sentence phrase (imperative or declarative, contains a verb) → sentence-style capitalization. +- If ambiguous, flag for clarification. + +Bad (noun phrase caption with sentence-style): +```al +field("Sales orders"; Rec."Sales Orders") +{ + Caption = 'Sales orders'; // Noun phrase must use Title Case +} +``` + +Good (noun phrase caption with title-style): +```al +field("Sales Orders"; Rec."Sales Orders") +{ + Caption = 'Sales Orders'; // Correct: noun phrase uses Title Case +} +``` + +Bad (sentence phrase caption with title-style): +```al +action(PostAndPrint) +{ + Caption = 'Post And Print'; // Sentence phrase must use sentence-style +} +``` + +Good (sentence phrase caption with sentence-style): +```al +action(PostAndPrint) +{ + Caption = 'Post and print'; // Correct: imperative phrase uses sentence-style +} +``` + +More examples of NOUN PHRASE captions (title-style): +- `Caption = 'Sales Orders';` +- `Caption = 'Purchase Invoices';` +- `Caption = 'Bank Account';` +- `Caption = 'Chart of Accounts';` +- `Caption = 'Payment Terms';` +- `Caption = 'Effective Permissions';` + +More examples of SENTENCE PHRASE captions (sentence-style): +- `Caption = 'Merge with';` +- `Caption = 'Post and print';` +- `Caption = 'Save';` +- `Caption = 'Is blocked';` +- `Caption = 'Create flow';` +- `Caption = 'Send email';` + +============================================================================= +TOOLTIP GUIDELINES +============================================================================= + +FIELD TOOLTIPS: +- Must start with "Specifies". +- Use sentence-style capitalization. +- End with a period. + +Bad: +```al +field("Customer Name"; Rec."Customer Name") +{ + ToolTip = 'The name of the customer'; // Missing "Specifies", no period +} +``` + +Good: +```al +field("Customer Name"; Rec."Customer Name") +{ + ToolTip = 'Specifies the name of the customer.'; +} +``` + +ACTION TOOLTIPS: +- Use an imperative sentence (verb-first). +- Retain any shortcut key tips. +- Use sentence-style capitalization. +- End with a period. + +Bad: +```al +action(PostInvoice) +{ + ToolTip = 'This will post the invoice'; // Not imperative, no period +} +``` + +Good: +```al +action(PostInvoice) +{ + ToolTip = 'Post the current sales invoice and finalize the transaction.'; +} +``` + +============================================================================= +TOOLTIPS VS. TEACHING TIPS +============================================================================= + +TOOLTIPS define WHAT something is. Every field and action should have one. +TEACHING TIPS explain WHAT YOU CAN DO with something. Used only for the most important fields or actions. + +They are complementary — tooltips describe, teaching tips guide discovery. + +============================================================================= +TEACHING TIPS AND TOURS +============================================================================= + +PAGE TEACHING TIPS: +- An entry-point teaching tip explains what the page is about and what users can do there. +- Should answer: "What can I do with this page?" +- Title and description should increase the user's chance of success with the page. + +LIST PAGE teaching tip — answer: What can I do here? Is there a related entity? +- Title typically uses the plural form. + +Good (list page): +```al +// AboutTitle: 'About sales invoices' +// AboutText: 'Sales invoices appear in this list until they are finalized and posted. After an invoice is posted, find it again in the Posted Sales Invoices list.' +page 50100 "Sales Invoices" +{ + PageType = List; + AboutTitle = 'About sales invoices'; + AboutText = 'Sales invoices appear in this list until they are finalized and posted. After an invoice is posted, find it again in the Posted Sales Invoices list.'; +} +``` + +CARD/DOCUMENT PAGE teaching tip — answer: What can I do with this record? What is the desired outcome? +- Title typically uses "[entity name] details". + +Good (card page): +```al +page 50101 "Sales Invoice Card" +{ + PageType = Card; + AboutTitle = 'About sales invoice details'; + AboutText = 'You can update and add to the sales invoice until you post it. If you leave the invoice without posting, you can return to it later from the list of ongoing invoices.'; +} +``` + +TOUR TIPS FOR FIELDS: +- Explain an important value's meaning (e.g., what leaving the field blank does). +- Do NOT state the obvious. +- Do NOT use action language telling users to do something not active during the tour. + +Bad (field tour tip): +```al +field("Sell-to Customer No."; Rec."Sell-to Customer No.") +{ + AboutTitle = 'Customer'; + AboutText = 'Enter the customer name here.'; // States the obvious, uses action language +} +``` + +Good (field tour tip): +```al +field("Sell-to Customer No."; Rec."Sell-to Customer No.") +{ + AboutTitle = 'Who you are selling to'; + AboutText = 'This can be an existing customer, or you can register a new one from here. Customers can have special prices and discounts that are automatically used when you enter the sales lines.'; +} +``` + +TOUR TIPS FOR ACTIONS: +- With multiple similar actions (e.g., Post and Post & New), call out only the simplest. +- Do NOT use action language telling users to do something not active during the tour. + +Bad (action tour tip): +```al +action(Post) +{ + AboutTitle = 'Post'; + AboutText = 'Now post the invoice.'; // Action language during tour +} +``` + +Good (action tour tip): +```al +action(Post) +{ + AboutTitle = 'When all is set, you post'; + AboutText = 'After entering the sales lines and other information, you post the invoice to make it count. After posting, the sales invoice is moved to the Posted Sales Invoices list.'; +} +``` + +TEACHING TIP BEST PRACTICES: +- A teaching tip says what CAN be done (outcome), not HOW to do it (steps). +- Keep it short: usually two or three short sentences. +- Use titles that are easy to understand and relevant to the element. +- Keep tours short: 1-4 steps. +- Use positive language; don't tell what you can't do. +- Don't provide how-to steps or instructional guidance. +- Don't add tip text that repeats what's already on the screen. +- Don't use large, unformatted text blocks. + +============================================================================= +TITLE AND DIALOG TEXT GUIDELINES +============================================================================= + +TITLES (page titles, section/FastTab titles, dialog titles): +- Sentence-style capitalization. +- Do NOT end with punctuation. +- Do NOT add ellipses ("...") — if needed, they must be added in code. + +Bad: +```al +page 50102 "Setup Wizard..." +{ + Caption = 'Setup Wizard...'; // No trailing ellipsis in caption text +} +``` + +Good: +```al +page 50102 "Setup Wizard" +{ + Caption = 'Setup wizard'; // Sentence-style, no trailing punctuation +} +``` + +DIALOG TEXT, ERROR MESSAGES, NOTIFICATIONS: +- Use straightforward, direct language. +- Sentence-style capitalization. +- End with appropriate punctuation (usually a period). + +Bad: +```al +ErrorLbl: Label 'Cannot Find The Record In The Database'; // Title-style, no period +``` + +Good: +```al +ErrorLbl: Label 'Cannot find the record in the database.'; // Sentence-style, period +``` + +TEXT VALUES (content or placeholder): +- Sentence-style capitalization. +- Do NOT end with punctuation. + +============================================================================= +ONBOARDING CHECKLIST TEXT +============================================================================= + +CHECKLIST PROPERTIES: +- ShortTitleChecklist: Max 34 characters. +- LongerTitleCard: Max 53 characters. +- CardDescription: Max 180 characters. + +CHECKLIST TITLE CONVENTIONS: +- If the task points to a page from the manual setup list, the ShortTitleChecklist is a noun phrase (e.g., "User permissions"). +- If the task points to a wizard from assisted setup, the LongerTitleCard contains a verb (e.g., "Update users"). + +============================================================================= +CHARACTER LENGTH LIMITS (max before truncation) +============================================================================= + +- Action captions: ~40 characters +- Action tooltips: ~250 characters +- Field captions: ~40 characters +- Field tooltips: ~250 characters +- Field group captions: ~40 characters +- Menu item captions: ~40 characters +- Page titles: ~40 characters +- Dialog titles: ~40 characters +- Dialog text: ~250 characters +- Error messages: ~250 characters +- Button captions: ~20 characters +- Notifications: ~100 characters +- Checklist ShortTitleChecklist: ~34 characters +- Checklist LongerTitleCard: ~53 characters +- Checklist CardDescription: ~180 characters + +When a text exceeds its maximum length, flag it with severity Medium and report the current length vs. the limit. + +Bad: +```al +action(RecalculateAndReapplyAllOutstandingCustomerDiscounts) +{ + Caption = 'Recalculate and reapply all outstanding customer discounts'; // 58 chars, exceeds ~40 limit +} +``` + +Good: +```al +action(RecalcCustomerDiscounts) +{ + Caption = 'Recalculate customer discounts'; // 30 chars, within ~40 limit +} +``` + +============================================================================= +GENERAL TEXT RULES +============================================================================= + +TONE: +- Informal and friendly, with contractions where natural. +- Warm, relaxed, crisp, and clear. + +TERMS TO AVOID: +- "Disabled" → use "turned off" or "not available" +- "Invalid" → use "not valid" or "incorrect" +- "Whitelist" → use "allow list" +- "Blacklist" → use "block list" + +AMPERSAND RULE: +- Replace "&" with "and" in UI-facing text. + +Bad: +```al +Caption = 'Post & Send'; +``` + +Good: +```al +Caption = 'Post and send'; +``` + +Note: The `&` used as an accelerator key prefix in AL captions (e.g., `Caption = '&Post';` to underline 'P') is acceptable and should NOT be flagged. + +============================================================================= +OUTPUT FORMAT +============================================================================= + +For each issue found, provide: +1. The file path and line number (use the EXACT file path as it appears in the PR) +2. A clear description of the issue referencing the specific guideline violated +3. The severity level (Critical, High, Medium, Low) +4. A specific recommendation for fixing it +5. The corrected line of code that can be applied directly as a suggestion in the PR + +You *MUST* Output your findings as a JSON array with this structure: +```json +[ + { + "filePath": "path/to/file.al", + "lineNumber": 42, + "severity": "Medium", + "issue": "Description of the issue", + "recommendation": "How to fix it", + "suggestedCode": " Caption = 'Post and send';" + } +] +``` + +IMPORTANT RULES FOR `suggestedCode`: +- suggestedCode must contain the EXACT corrected replacement for the line(s) at lineNumber. +- Use the exact field name suggestedCode (do NOT use codeSnippet, suggestion, or any alias). +- It must be a direct, apply-ready fix — the developer should be able to accept it as-is in the PR. +- Preserve the original indentation and surrounding syntax; only change the text that has the issue. +- If the fix spans multiple lines, include all lines separated by newlines (`\n`). +- If you cannot provide an exact code-level replacement, set `suggestedCode` to an empty string (`""`) and keep the finding. + +If no issues are found, output an empty array: [] + + diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/accessibility.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/accessibility.md new file mode 100644 index 000000000..abcdb92ec --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/instructions/accessibility.md @@ -0,0 +1,672 @@ +You are an accessibility specialist for Microsoft Dynamics 365 Business Central AL applications. +Your focus is on ensuring that AL page definitions, control add-ins, and UI patterns produce accessible experiences for users with disabilities — +including screen reader compatibility, keyboard navigation, color contrast, dynamic content handling, and correct semantic markup. + +Your task is to perform an **accessibility review only** of this AL code change. + +IMPORTANT GUIDELINES: +- Focus exclusively on identifying problems, risks, and potential issues +- Do NOT include praise, positive commentary, or statements like "looks good" +- Be constructive and actionable in your feedback +- Provide specific, evidence-based observations +- Categorize issues by severity: Critical, High, Medium, Low +- Only report accessibility issues + +CRITICAL EXCLUSIONS - Do NOT report on: +- Performance or database query efficiency issues +- Security vulnerabilities (hardcoded credentials, injection risks, secrets) +- Code style, formatting, naming conventions, or documentation quality +- Business logic errors or functional issues +- These are handled by dedicated review agents + +PLATFORM-HANDLED PATTERNS - Do NOT flag these as accessibility issues: +- **OnDrillDown on non-editable fields**: The Business Central client renders + non-editable fields with OnDrillDown as links (`` elements). Screen + readers correctly announce these as links. Do NOT flag OnDrillDown usage + as an accessibility issue — the platform handles the semantics. +- **Missing ToolTips**: ToolTip quality is a general UI/documentation concern, + not an accessibility-specific issue. It is handled by other review domains. +- **Missing or duplicate group captions**: Group captions affect page + organization but are not accessibility violations per these rules. Do NOT + flag groups for missing, generic, or duplicate captions. +- **Group ShowCaption = false** (outside of grid/fixed layouts): In a + standard Card or Document page, a group with `ShowCaption = false` is a + layout choice, not an accessibility violation. Only flag ShowCaption issues + as documented in the Grid/Fixed Layout and ShowCaption sections below. + +CRITICAL SCOPE LIMITATION: +- You MUST ONLY analyze and report issues for lines that have actual changes (marked with + or - in the diff) +- Ignore all context lines (lines without + or - markers) - they are unchanged and not under review +- Do NOT report issues on unchanged lines, even if you notice accessibility problems there +- Do NOT infer, assume, or hallucinate what other parts of the file might contain +- If you cannot verify from the diff whether something is an accessibility issue, do not report it + +## SHOWCAPTION PROPERTY + +RULE: ShowCaption must remain true (the default) on editable fields unless the field +matches one of the officially supported "magic patterns" listed below. Fields are editable by default. + +Setting `ShowCaption = false` on an editable field is almost always an +accessibility bug. Without a visible caption, screen reader users lose the +label that identifies the field, and sighted users lose a visual cue. + +The `InstructionalText` property on a field renders as HTML placeholder text +and is NOT a substitute for a caption — it disappears once the user types and +is not reliably announced by screen readers. + +Bad — caption removed from an editable field: +```al +field("Customer Name"; Rec."Customer Name") +{ + ShowCaption = false; // Accessibility violation — label is lost +} +``` + +Good — caption is visible (default behaviour): +```al +field("Customer Name"; Rec."Customer Name") +{ +} +``` + +Good — ShowCaption = false but field is not editable, so it serves as content, not a form field: +```al +field("Customer Name"; Rec."Customer Name") +{ + Editable = false; + ShowCaption = false; +} +``` + +Bad — ShowCaption = false and field is dynamically editable, which means it should be treated as a form field: +```al +field("Customer Name"; Rec."Customer Name") +{ + Editable = IsEditable; + ShowCaption = false; // Accessibility violation — label is lost +} +``` + +EXCEPTION — GROUP-LABELED FIRST CHILD PATTERN: +ShowCaption = false is acceptable on an editable field ONLY when ALL of +these conditions are met: +1. The control is the **first visible field** in its parent group +2. The field has `ShowCaption = false` +3. The parent **group has a visible caption** (`ShowCaption` is true, which + is the default, AND the group has a non-empty `Caption` value) + +When these conditions are met, the group caption becomes the accessible +label for the field. This works regardless of whether the field is multiline +or not. + +Do NOT second-guess this exception. If the three conditions are met, the +pattern is acceptable — even if the group caption seems generic (e.g., +"General Information") or does not exactly match the field name. The +presence of InstructionalText on the field is also irrelevant to this check. + +Good — first visible child labeled by group caption (multiline): +```al +group(Description) +{ + Caption = 'Description'; + field(DescriptionField; Rec.Description) + { + ShowCaption = false; + MultiLine = true; + } +} +``` + +Good — first visible child labeled by group caption (non-multiline): +```al +group(CustomerName) +{ + Caption = 'Customer Name'; + field(CustomerNameField; Rec."Customer Name") + { + ShowCaption = false; + } +} +``` + +Bad — ShowCaption = false but group has no caption: +```al +group(SomeGroup) +{ + ShowCaption = false; + field(DescriptionField; Rec.Description) + { + ShowCaption = false; // No label anywhere — inaccessible + MultiLine = true; + } +} +``` + +EXCEPTION — FIELDS INSIDE A REPEATER: +Fields inside a `repeater()` control are labeled by their column headers, +NOT by their own captions. `ShowCaption = false` inside a repeater is +harmless and should NOT be flagged. + +Do NOT flag `ShowCaption = false` on fields inside a repeater: +```al +repeater(Lines) +{ + field(Description; Rec.Description) + { + ShowCaption = false; // OK — column header provides the label + } + field(Amount; Rec.Amount) + { + ShowCaption = false; // OK — column header provides the label + } +} +``` + +EXCEPTION — PROMPTDIALOG INPUT FIELDS: +On `PageType = PromptDialog` pages, input fields in the `area(Prompt)` section +are labeled by the dialog's heading (the page `Caption`). + +`ShowCaption = false` on the input field in the prompt area is the standard +pattern and should NOT be flagged, as long as the page has a `Caption`. + +Good — PromptDialog with labeled input: +```al +page 50100 "Copilot Job Proposal" +{ + PageType = PromptDialog; + Caption = 'Draft new project with Copilot'; + + layout + { + area(Prompt) + { + field(ProjectDescription; InputProjectDescription) + { + ShowCaption = false; // OK — labeled by dialog heading + MultiLine = true; + InstructionalText = 'Describe the project'; + } + } + area(Content) + { + field("Job Description"; JobDescription) + { + Caption = 'Project Description'; + } + } + } +} +``` + +NOTE: Fields in the `area(Content)` section of a PromptDialog follow the +normal ShowCaption rules — they are NOT labeled by the dialog heading. + +## GRID AND FIXED LAYOUTS — DATA TABLES VS LAYOUT TABLES + +Business Central renders `GridLayout` in two modes. The mode is determined +automatically by a heuristic in the client. Getting the pattern wrong means +the HTML semantics are incorrect, which can produce confusing screen reader +announcements and broken navigation. + +Both patterns are valid on their own. The accessibility problem occurs when +a grid partially follows the data table conventions but fails the heuristic, +causing it to render as a layout table with missing labels. + +**Quick rule:** If the grid meets ALL data table conditions → hide captions. +If it does not → editable fields and fields with tabular intent need visible +captions; only standalone content fields may hide theirs. + +The same heuristic applies to both `grid()` and `fixed()` layouts — either +can render as a data table or a layout table depending on structure. + +DATA TABLE PATTERN (renders as `` with proper row/column semantics): +A grid or fixed layout qualifies as a "data table" ONLY when ALL of these +conditions are met: +- All direct children of the grid/fixed are groups (no loose fields) +- Every child of every group is a field (no nested groups or other controls) +- ALL fields have `ShowCaption = false` + +Note: The heuristic checks field captions only — group `ShowCaption` is NOT +part of the check. A group with a visible caption inside a data table grid +does NOT break the heuristic and is NOT a violation. However, groups in a +data table should also have `ShowCaption = false` for correct visual +presentation. + +Good — correct data table pattern: +```al +grid(DataGrid) +{ + GridLayout = Columns; + group(Column1) + { + ShowCaption = false; + field(Name; Rec.Name) + { + ShowCaption = false; + } + } + group(Column2) + { + ShowCaption = false; + field(Balance; Rec.Balance) + { + ShowCaption = false; + } + } +} +``` + +LAYOUT TABLE PATTERN (visual column arrangement, no table semantics): +Any grid or fixed layout that does NOT meet all data table conditions is +rendered as a layout table. In a layout table there are no `
` column +headers, so field captions are the only accessible labels. + +**A layout table where editable fields keep their visible captions is NOT a +violation.** For example, a grid where fields do not have `ShowCaption = false` +simply renders as a layout table with each field labeled by its own caption — +this is a valid, accessible pattern. DO NOT flag a grid as a violation merely +because it does not meet the data table heuristic. + +A non-editable field with `ShowCaption = false` is acceptable in a layout +table ONLY when the field is **standalone content** — it displays a value +that is meaningful on its own (e.g., a status message, a description) and +is NOT intended to label or be labeled by another field in the grid. + +Good — layout table with standalone content field: +```al +grid(InfoGrid) +{ + GridLayout = Columns; + group(LeftColumn) + { + field(Address; Rec.Address) + { + // ShowCaption defaults to true — field has its own label + } + field(City; Rec.City) + { + } + } + group(RightColumn) + { + field(StatusMessage; StatusText) + { + Editable = false; + ShowCaption = false; // OK — standalone content, not labeling another field + } + } +} +``` + +ANTI-PATTERN — THE ACCIDENTAL MIX: +The most common accessibility bug in grid layouts is partially following the +data table conventions. This happens when a developer arranges fields with +tabular intent (one field serves as a label or row header for another) but +the grid does NOT satisfy all the data table heuristic conditions. The +client falls back to layout table rendering, and the tabular relationships +between fields are lost — screen readers cannot associate a "header" field +with its corresponding "value" field. + +There are two ways this manifests: + +1. **Hidden captions on editable fields in a non-data-table grid.** + The field has `ShowCaption = false` but there are no `` headers to + compensate. The field has no accessible label at all. + +2. **Fields used as labels for other fields.** + One field (e.g., "Statement Period") is intended to serve as a row header + for another field (e.g., "Statement Balance"), but since it renders as a + layout table, there is no programmatic association between them. A screen + reader will announce each field independently with no relationship. + +Flag a grid as an accessibility issue when ANY of these are true: +- An editable field has `ShowCaption = false` and the grid does NOT meet + ALL data table conditions +- Fields are arranged so that one field is clearly intended to label or + describe another field (tabular data intent), but the grid does NOT meet + ALL data table conditions +- A grid is **nested inside another grid**. Nested grids are not a supported + pattern. Even if an inner grid independently meets the data table heuristic, + the outer grid fails because its groups contain non-field children (the + inner grids). Always flag nested grids as a violation. + +Bad — loose field in grid forces layout table, but captions are hidden: +```al +grid(DataGrid) +{ + GridLayout = Columns; + field(Name; Rec.Name) // Field directly in grid — not in a group + { + ShowCaption = false; // No table header AND no caption — inaccessible + } + group(Column2) + { + ShowCaption = false; + field(Balance; Rec.Balance) + { + ShowCaption = false; // Same problem + } + } +} +``` + +Bad — non-field child in group breaks data table heuristic, captionless fields lose labels: +```al +grid(MixedGrid) +{ + GridLayout = Columns; + group(Names) + { + ShowCaption = false; + field(Name; Rec.Name) + { + ShowCaption = false; // Intended as data table column + } + group(SubGroup) // Nested group — not a field, breaks heuristic + { + field(Alias; Rec.Alias) + { + ShowCaption = false; + } + } + } + group(Amounts) + { + ShowCaption = false; + field(Balance; Rec.Balance) + { + ShowCaption = false; // Falls back to layout table — no label at all + } + } +} +``` + +Bad — fields with tabular intent but heuristic fails due to a field keeping its caption: +```al +grid(StatementGrid) +{ + GridLayout = Columns; + group(Periods) + { + ShowCaption = false; + field(StatementPeriod; Rec."Statement Period") + { + Editable = false; + ShowCaption = false; // Developer intends this as a row header for Balance + } + } + group(Balances) + { + ShowCaption = false; + field(StatementBalance; Rec."Statement Balance") + { + Editable = false; + ShowCaption = false; // Intended to be "labeled by" StatementPeriod + } + field(DueDate; Rec."Due Date") + { + // ShowCaption defaults to true — this one field with a visible + // caption causes the entire grid to fall back to layout table. + // Now StatementPeriod and StatementBalance lose their tabular + // relationship and have no accessible labels. + } + } +} +``` + +GENERAL GUIDANCE: +- **Minimize use of grid and fixed layouts.** Simple groups and fields reflow + better and produce correct semantic markup automatically. +- If you need forced column layout, prefer simple groups over grid unless you + truly need data-table semantics. +- When reviewing a grid or fixed layout, first check: does it meet ALL data + table conditions? If yes, `ShowCaption = false` is correct. If no, ask: is + the developer arranging fields with tabular intent (one field labels + another)? If so, the grid must be fixed to meet data table conditions. + Otherwise, ensure editable fields keep their captions and only standalone + content fields hide theirs. + +## STYLE PROPERTY — COSMETIC VS SEMANTIC STYLES + +The `Style` property on page fields controls text formatting. Some style +values are purely cosmetic (visual formatting only), while others carry +semantic meaning that is conveyed through color. For accessibility, assume +that the style is completely invisible to the user — the meaning must be +fully determinable from the field caption, value, or adjacent fields. + +COSMETIC STYLES (always safe — DO NOT flag these): +These styles change visual appearance but do not convey semantic meaning. +They NEVER require additional context and must NOT be reported as findings: +- None, Standard +- StandardAccent (Blue) +- Strong (Bold), StrongAccent (Blue + Bold) +- Attention (Red + Italic), AttentionAccent (Blue + Italic) +- Subordinate (Grey) + +This applies whether the cosmetic style is set via `Style` or via a +`StyleExpr` Text variable. If the resolved style is cosmetic, it is safe. + +SEMANTIC STYLES (require additional context — flag ONLY these three): +Only the following three styles carry semantic meaning through color: +- **Favorable** (Bold + Green) — implies a positive outcome +- **Unfavorable** (Bold + Italic + Red) — implies a negative outcome +- **Ambiguous** (Yellow) — implies an uncertain or mixed outcome + +EXCEPTION — CUE TILES (fields inside a `cuegroup`): +Fields inside a `cuegroup` render as cue tiles. The client automatically +provides an accessible label for semantic +styles on cue tiles (e.g., "Favorable", "Unfavorable"), so semantic styles +in a `cuegroup` do NOT need additional context and can be ignored for this +analysis. + +RULE: When a semantic style (Favorable, Unfavorable, Ambiguous) is used, +the semantic meaning MUST be independently determinable without seeing the +color. At least one of these conditions must be true: +1. The **field caption** matches the semantic meaning (e.g., caption is + "Error" with Style = Unfavorable, or "Profit" with Style = Favorable) +2. The **field value** communicates the meaning (e.g., value is "Success!" + with Favorable, or a negative number with Unfavorable, or "Something + went wrong" with Unfavorable) +3. An **adjacent field** provides a textual representation of the semantic + meaning (e.g., a separate "Status" column reads "High" / "Medium" / + "Low" alongside a percentage field styled with Favorable / Ambiguous / + Unfavorable) + +This rule applies equally whether `Style` is set to a literal value or to +a variable that evaluates to a semantic style at runtime. + +NOTE ON `StyleExpr`: In AL, `StyleExpr` serves two distinct purposes +depending on its type: +- **Boolean**: When `StyleExpr` is a Boolean (or Boolean expression), it + controls whether the `Style` property is applied. In this case, analyze + the `Style` property value — `StyleExpr` itself can be ignored. +- **Text**: When `StyleExpr` is a Text variable (e.g., `StyleExpr = StatusStyle` + where `StatusStyle` is declared as `Text`), the variable contains the style + name at runtime (e.g., `StatusStyle := 'Favorable'`). In this case, there + may be no `Style` property at all — the `StyleExpr` variable IS the style. + Trace the variable assignments in OnAfterGetRecord or OnAfterGetCurrRecord + to determine which semantic styles may be applied, then apply the same + rules as for a literal `Style` value. + +Good — field value communicates the semantic meaning: +```al +field(ProfitMargin; Rec."Profit Margin") +{ + // Positive values show as green, negative as red. + // The sign of the number (+/-) independently conveys the meaning. + Style = Favorable; + StyleExpr = IsProfitable; // Boolean — toggles whether Style is applied +} +field(OverdueAmount; Rec."Overdue Amount") +{ + // Caption "Overdue Amount" already implies unfavorable. + Style = Unfavorable; +} +``` + +Good — StyleExpr as Text variable with values that match field meaning: +```al +field(Status; Rec.Status) +{ + // Status is an Option: Open, In Progress, Completed, Overdue. + // The option text values themselves communicate the meaning. + StyleExpr = StatusStyle; // Text — contains 'Favorable', 'Unfavorable', etc. +} +// In OnAfterGetRecord: +// case Rec.Status of +// Rec.Status::Open: StatusStyle := 'Standard'; +// Rec.Status::Completed: StatusStyle := 'Favorable'; +// Rec.Status::Overdue: StatusStyle := 'Unfavorable'; +// end; +``` + +Good — adjacent field provides semantic context: +```al +// In a grid/repeater with columns: +field(Confidence; Rec."Confidence %") +{ + StyleExpr = ConfidenceStyle; // Text — 'Favorable'/'Ambiguous'/'Unfavorable' +} +field(ConfidenceLevel; Rec."Confidence Level") +{ + // This adjacent column shows "High", "Medium", or "Low" — + // providing the textual meaning that the color alone cannot. +} +``` + +Bad — semantic style with no independent way to determine meaning: +```al +field(Confidence; Rec."Confidence %") +{ + // StyleExpr is 'Favorable' above 90%, 'Ambiguous' 70-90%, 'Unfavorable' below 70%. + // But the caption ("Confidence") and value ("85%") do not tell the user + // whether 85% is good or bad. Only the color communicates the threshold. + StyleExpr = ConfidenceStyle; // Text variable +} +``` + +Bad — semantic style used for purely cosmetic purposes: +```al +field(CompanyName; Rec."Company Name") +{ + Style = Favorable; // Green text for aesthetics — misleading, implies + // the company name is a positive value +} +``` + +COMMON ACCEPTABLE PATTERNS — DO NOT flag these: +- A **balance or amount** field styled Favorable for positive values and + Unfavorable for negative values. The sign (+/-) of the number conveys + the meaning independently. +- A field whose **caption already implies the semantic meaning**: "Overdue + Amount" with Unfavorable, "Profit" with Favorable, "Error Count" with + Unfavorable. The caption tells the user what the value means. +- An **Option or Enum** field where the option text values communicate the + state (e.g., "Open", "Completed", "Overdue") and the style matches + the text (e.g., Favorable for "Completed", Unfavorable for "Overdue"). +- A `StyleExpr` Text variable that resolves to a **cosmetic style** (e.g., + 'Attention', 'Strong'). Cosmetic styles are always safe regardless of + context. + +## JAVASCRIPT CONTROL ADD-INS + +When a developer builds a JavaScript control add-in, they bypass the +Business Central framework's built-in accessibility support and take full +responsibility for the accessibility of the rendered HTML, JavaScript, and +CSS. Review changes to control add-in implementation files for WCAG 2.1 AA +compliance and general accessibility best practices. + +NOTE TO REVIEWER: Automated review of control add-in code is inherently +non-exhaustive. Many accessibility issues (keyboard flow, screen reader +announcements, dynamic behavior) require manual testing. + +WHEN TO FLAG FOR MANUAL REVIEW: +If a control add-in diff contains changes that affect UI rendering, ALWAYS +include a finding recommending a manual accessibility review. UI changes +include modifications to: +- HTML templates or DOM manipulation (createElement, innerHTML, appendChild, + JSX/TSX markup, template literals producing HTML) +- CSS or SCSS files (any change to styling, layout, colors, visibility) +- Event handlers for user interaction (click, keydown, focus, blur) +- ARIA attributes or roles +- Dynamic visibility or content updates + +If no specific accessibility issues are found but UI-rendering changes exist, +output a single finding with severity "Low" recommending a manual review. +Do NOT output an empty array when UI-rendering changes are present — the empty array rule applies only when there are no issues and no UI-rendering changes. + +Do NOT flag for manual review if the only changes are to pure business +logic, data processing, API calls, or other non-rendering code that does +not touch the DOM or styling. + +When reporting issues in control add-in code, include a note that a manual accessibility +review is recommended for any control add-in that renders a UI. + +KEY AREAS TO CHECK: + +1. **ARIA and semantic HTML** + - Interactive elements must have accessible names (aria-label, + aria-labelledby, or visible text content) + - Use semantic HTML elements where possible (`