From bae416f47d170248bb244bfa4e30b8ad0e6e1587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B0=D1=80=D0=B0=D1=81=D0=BE=D0=B2=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Fri, 31 Jul 2026 09:05:27 +0300 Subject: [PATCH 1/8] =?UTF-8?q?code=5Freview:=20=D0=BE=D0=B1=D1=91=D1=80?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20BSL=20Language=20Server=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый тул code_review. Стандарты РАЗРАБОТКИ (пакет v8-code-style) уже показываются через get_project_errors; но метрики кода (магическое число, цикломатическая/когнитивная сложность, длина метода/строки, вложенность) в EDT нет вообще. Своих правил не пишем - оборачиваем внешний движок BSL Language Server (github.com/1c-syntax/bsl-language-server, LGPL-3.0). Архитектура: движок = ВНЕШНИЙ jar (не в git, не в bundle) - подпроцесс `java -jar exec.jar --analyze --reporter json`, найденный через env EDT_MCP_BSL_LS_JAR (+ EDT_MCP_BSL_LS_JAVA для выбора Java, т.к. линейка 1.x движка собрана под Java 21, а 0.28.x - под Java 17). Обновление движка = подмена файла без пересборки плагина. Конфиг проверок - проектный .bsl-language-server.json, иначе дефолты движка. Вывод рассчитан на авто-ремедиацию агентом: таблица Severity|Rule| Module path|Line|Message|Docs плюс явная инструкция "чини на Module path+Line через write_module_source, потом перезапусти code_review для проверки". Module path релятивизован к src/ - те же координаты, что read/write_module_source. BslLsReport - чистый парсер JSON-отчёта движка (без IO): конвертирует LSP 0-based строку/колонку в 1-based, нормализует путь (движок пишет file:// с ../ относительно своего рабочего каталога), маппит severity. BslLsRunner - подпроцесс: поиск jar/Java, запуск, парсинг, actionable-ошибка при отсутствии движка вместо исключения. Тесты: unit (BslLsReportTest, BslLsRunnerTest, CodeReviewToolTest) + e2e (happy-пути дают skip, если движок не настроен в окружении - специально для CI без внешней зависимости; негативные сценарии детерминированы и не требуют движка). Зарегистрирован в тулсете PROJECT. Примечание: README-индекс тулов (docs/tools/README.md, таблица в README.md) не тронут в этом PR - это общий сгенерированный блок, конфликтующий между параллельными PR; предлагаем регенерировать один раз после мержа нескольких PR (python docs/generate_tool_docs.py против живого сервера). --- docs/tools/code_review.md | 63 +++ .../guides/code_review.md | 46 ++ .../server/tools/BuiltInToolRegistrar.java | 2 + .../ditrix/edt/mcp/server/tools/Toolsets.java | 1 + .../mcp/server/tools/impl/CodeReviewTool.java | 373 +++++++++++++ .../edt/mcp/server/utils/BslLsReport.java | 459 ++++++++++++++++ .../edt/mcp/server/utils/BslLsRunner.java | 519 ++++++++++++++++++ .../server/tools/impl/CodeReviewToolTest.java | 196 +++++++ .../edt/mcp/server/utils/BslLsReportTest.java | 190 +++++++ .../edt/mcp/server/utils/BslLsRunnerTest.java | 167 ++++++ tests/e2e/tools/test_code_review.py | 162 ++++++ 11 files changed, 2178 insertions(+) create mode 100644 docs/tools/code_review.md create mode 100644 mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md create mode 100644 mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java create mode 100644 mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsReport.java create mode 100644 mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java create mode 100644 mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java create mode 100644 mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsReportTest.java create mode 100644 mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java create mode 100644 tests/e2e/tools/test_code_review.py diff --git a/docs/tools/code_review.md b/docs/tools/code_review.md new file mode 100644 index 000000000..6cac82825 --- /dev/null +++ b/docs/tools/code_review.md @@ -0,0 +1,63 @@ +# code_review + +Review BSL code quality with the BSL Language Server engine: reports code-metric defects (magic number, cyclomatic/cognitive complexity, method/line length, nesting, …) that EDT's own checks do not cover. Each finding is a defect to FIX: it carries the rule, severity, Module path and Line, ready for read_module_source / write_module_source — fix each, then re-run code_review to verify. Scope the whole project or one module; filter by severity or rule. Needs the engine jar (see the guide). Full parameters and examples: call get_tool_guide('code_review'). + +## Parameters +| Parameter | Required | Type | Description | +| --- | --- | --- | --- | +| projectName | yes | string | EDT project name to review. | +| modulePath | — | string | Optional: narrow the review to a single module, path from src/ (e.g. 'CommonModules/Calc/Module.bsl'). Omit to review the whole configuration. | +| severity | — | string (one of: error, warning, information, hint) | Optional: minimum severity to report (error > warning > information > hint). Omit to report all. | +| rule | — | string | Optional: report only diagnostics whose rule id contains this substring (e.g. 'Magic', 'Complexity'). | +| limit | — | integer | Max findings; default 100, max 1000 (optional). | + +## Guide +Review BSL code quality by running the external BSL Language Server engine over a project (or a single module) and reporting its diagnostics as an actionable table. Every finding is a concrete defect located by `Module path` + `Line` — the same coordinates `read_module_source` and `write_module_source` use — so the intended workflow is **review → fix → re-run to verify**. + +## When to use +- To surface code-metric defects EDT's own checks do not raise: magic numbers/dates, cyclomatic & cognitive complexity, method/line length, parameter counts, nesting, deprecated calls, service tags, and more. +- As the first step of an automated clean-up loop: run `code_review`, fix each finding in place with `write_module_source`, then run `code_review` again (optionally scoped to the one module) to confirm the finding is gone. +- Prefer `get_project_errors` when you want EDT's configuration-development standards (`v8-code-style`) — that half is already covered there. `code_review` is the BSL Language Server metric layer on top. + +## How the findings should be handled +The rows are defects to FIX, not just a report: +- **Mechanical** issues (e.g. `MagicNumber`, `MagicDate`, an unused variable, a missing comment space) can be fixed directly with `write_module_source`. +- **Complexity / nesting / length** issues (e.g. `CyclomaticComplexity`, `CognitiveComplexity`, `NestedTernaryOperator`) usually need a judged refactor — extract a method, invert a guard, split a loop — so apply care and keep behaviour identical. +- After fixing, re-run `code_review` (scope it with `modulePath` for a fast check) to verify the finding is resolved before moving on. + +## Parameter details +- `projectName` (required) — the EDT project to review. +- `modulePath` — narrow the review to a single module, given as a path from `src/` (e.g. `CommonModules/Calc/Module.bsl`). Omit to review the whole configuration. This is the same path form the `Module path` column returns, so you can feed a row straight back in. +- `severity` — minimum severity to report: `error` > `warning` > `information` > `hint`. Omit to report every severity. (These are the engine's LSP severities, independent of EDT's BLOCKER/MAJOR/… taxonomy.) +- `rule` — report only diagnostics whose rule id contains this substring, case-insensitive (e.g. `Magic`, `Complexity`, `Unused`). Handy for a focused pass or a targeted re-verify. +- `limit` — maximum number of rows to render; default 100, capped at 1000. The summary counts above the table always reflect the full report, not the capped table. + +## Output +- Markdown. A heading with the scope, a one-line summary of counts per severity, a short instruction to fix-and-re-verify, then a table with columns: `Severity`, `Rule`, `Module path`, `Line`, `Message`, `Docs` (the rule's documentation URL). +- A clean project renders "No BSL code-quality issues found." with no table. +- When filters exclude everything (but the project did have findings) the table is replaced by a "_No findings match the current filters._" note. + +## The engine (jar + Java) — one-time setup +`code_review` does not implement any rules; it calls the BSL Language Server engine as a subprocess over its stable CLI (`--analyze --reporter json`). Provide the engine once: +- **Jar** — download `bsl-language-server--exec.jar` from and point `EDT_MCP_BSL_LS_JAR` at it (or place it in `/bsl-language-server`). The rules are compiled into the jar; to get newer rules, swap in a newer jar — no plugin rebuild. +- **Java** — the `1.x` engine line needs Java 21; the `0.28.x` line runs on Java 17. Set `EDT_MCP_BSL_LS_JAVA` to a suitable `java` executable; if unset, the JRE running EDT is used (Java 17 → pair it with a `0.28.x` jar). +- If the jar or Java cannot be found, the tool returns an actionable error naming the environment variable to set and the download page. + +## Which checks run +- The engine reads the project's own `.bsl-language-server.json` (at the project root) if present; otherwise a `.bsl-language-server.json` sitting next to the jar (the "engine home"); otherwise the engine defaults. +- Use that file to enable/disable rules (`"parameters": { "SomeRule": false }`), tune thresholds (`"MagicNumber": { "authorizedNumbers": "-1,0,1" }`) and set the message language (`"diagnosticLanguage": "en"`). Exact per-rule parameter names are on each rule's documentation page (the `Docs` column URL). + +## Examples +- Whole project: `{projectName: "MyProject"}`. +- One module, fast re-verify after a fix: `{projectName: "MyProject", modulePath: "CommonModules/Calc/Module.bsl"}`. +- Only the important ones: `{projectName: "MyProject", severity: "warning"}`. +- Only magic numbers: `{projectName: "MyProject", rule: "Magic"}`. + +## Notes & gotchas +- Line numbers are 1-based (converted from the engine's 0-based LSP output), matching `read_module_source`/`set_breakpoint`. +- `Module path` is relativized to `src/`; a finding outside `src/` (rare) shows its absolute path instead. +- The engine analyzes files on disk. If you just edited a module through the model, ensure it is exported to disk (the write tools do this) before reviewing, or the review may read a stale file. +- A large configuration can take a while to analyze; scope with `modulePath` for quick iterative checks. + +--- +*Generated from the live MCP server (`get_tool_guide`) by `docs/generate_tool_docs.py`. Do not edit this file. Edit the tool's description/schema in its Java source and its guide body in `mcp/bundles/com.ditrix.edt.mcp.server/guides/.md`.* diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md b/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md new file mode 100644 index 000000000..7ca1303a0 --- /dev/null +++ b/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md @@ -0,0 +1,46 @@ +Review BSL code quality by running the external BSL Language Server engine over a project (or a single module) and reporting its diagnostics as an actionable table. Every finding is a concrete defect located by `Module path` + `Line` — the same coordinates `read_module_source` and `write_module_source` use — so the intended workflow is **review → fix → re-run to verify**. + +## When to use +- To surface code-metric defects EDT's own checks do not raise: magic numbers/dates, cyclomatic & cognitive complexity, method/line length, parameter counts, nesting, deprecated calls, service tags, and more. +- As the first step of an automated clean-up loop: run `code_review`, fix each finding in place with `write_module_source`, then run `code_review` again (optionally scoped to the one module) to confirm the finding is gone. +- Prefer `get_project_errors` when you want EDT's configuration-development standards (`v8-code-style`) — that half is already covered there. `code_review` is the BSL Language Server metric layer on top. + +## How the findings should be handled +The rows are defects to FIX, not just a report: +- **Mechanical** issues (e.g. `MagicNumber`, `MagicDate`, an unused variable, a missing comment space) can be fixed directly with `write_module_source`. +- **Complexity / nesting / length** issues (e.g. `CyclomaticComplexity`, `CognitiveComplexity`, `NestedTernaryOperator`) usually need a judged refactor — extract a method, invert a guard, split a loop — so apply care and keep behaviour identical. +- After fixing, re-run `code_review` (scope it with `modulePath` for a fast check) to verify the finding is resolved before moving on. + +## Parameter details +- `projectName` (required) — the EDT project to review. +- `modulePath` — narrow the review to a single module, given as a path from `src/` (e.g. `CommonModules/Calc/Module.bsl`). Omit to review the whole configuration. This is the same path form the `Module path` column returns, so you can feed a row straight back in. +- `severity` — minimum severity to report: `error` > `warning` > `information` > `hint`. Omit to report every severity. (These are the engine's LSP severities, independent of EDT's BLOCKER/MAJOR/… taxonomy.) +- `rule` — report only diagnostics whose rule id contains this substring, case-insensitive (e.g. `Magic`, `Complexity`, `Unused`). Handy for a focused pass or a targeted re-verify. +- `limit` — maximum number of rows to render; default 100, capped at 1000. The summary counts above the table always reflect the full report, not the capped table. + +## Output +- Markdown. A heading with the scope, a one-line summary of counts per severity, a short instruction to fix-and-re-verify, then a table with columns: `Severity`, `Rule`, `Module path`, `Line`, `Message`, `Docs` (the rule's documentation URL). +- A clean project renders "No BSL code-quality issues found." with no table. +- When filters exclude everything (but the project did have findings) the table is replaced by a "_No findings match the current filters._" note. + +## The engine (jar + Java) — one-time setup +`code_review` does not implement any rules; it calls the BSL Language Server engine as a subprocess over its stable CLI (`--analyze --reporter json`). Provide the engine once: +- **Jar** — download `bsl-language-server--exec.jar` from and point `EDT_MCP_BSL_LS_JAR` at it (or place it in `/bsl-language-server`). The rules are compiled into the jar; to get newer rules, swap in a newer jar — no plugin rebuild. +- **Java** — the `1.x` engine line needs Java 21; the `0.28.x` line runs on Java 17. Set `EDT_MCP_BSL_LS_JAVA` to a suitable `java` executable; if unset, the JRE running EDT is used (Java 17 → pair it with a `0.28.x` jar). +- If the jar or Java cannot be found, the tool returns an actionable error naming the environment variable to set and the download page. + +## Which checks run +- The engine reads the project's own `.bsl-language-server.json` (at the project root) if present; otherwise a `.bsl-language-server.json` sitting next to the jar (the "engine home"); otherwise the engine defaults. +- Use that file to enable/disable rules (`"parameters": { "SomeRule": false }`), tune thresholds (`"MagicNumber": { "authorizedNumbers": "-1,0,1" }`) and set the message language (`"diagnosticLanguage": "en"`). Exact per-rule parameter names are on each rule's documentation page (the `Docs` column URL). + +## Examples +- Whole project: `{projectName: "MyProject"}`. +- One module, fast re-verify after a fix: `{projectName: "MyProject", modulePath: "CommonModules/Calc/Module.bsl"}`. +- Only the important ones: `{projectName: "MyProject", severity: "warning"}`. +- Only magic numbers: `{projectName: "MyProject", rule: "Magic"}`. + +## Notes & gotchas +- Line numbers are 1-based (converted from the engine's 0-based LSP output), matching `read_module_source`/`set_breakpoint`. +- `Module path` is relativized to `src/`; a finding outside `src/` (rare) shows its absolute path instead. +- The engine analyzes files on disk. If you just edited a module through the model, ensure it is exported to disk (the write tools do this) before reviewing, or the review may read a stale file. +- A large configuration can take a while to analyze; scope with `modulePath` for quick iterative checks. diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/BuiltInToolRegistrar.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/BuiltInToolRegistrar.java index e2a232bb3..07a348cb8 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/BuiltInToolRegistrar.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/BuiltInToolRegistrar.java @@ -10,6 +10,7 @@ import com.ditrix.edt.mcp.server.tools.impl.AdoptMetadataObjectTool; import com.ditrix.edt.mcp.server.tools.impl.BuildExternalObjectsTool; import com.ditrix.edt.mcp.server.tools.impl.CleanProjectTool; +import com.ditrix.edt.mcp.server.tools.impl.CodeReviewTool; import com.ditrix.edt.mcp.server.tools.impl.CreateGitBranchTool; import com.ditrix.edt.mcp.server.tools.impl.CreateInfobaseTool; import com.ditrix.edt.mcp.server.tools.impl.SetInfobaseCredentialsTool; @@ -138,6 +139,7 @@ public static void registerAll(McpToolRegistry registry) registry.register(new CreateProjectTool()); registry.register(new GetProblemSummaryTool()); registry.register(new GetProjectErrorsTool()); + registry.register(new CodeReviewTool()); registry.register(new GetMarkersTool()); registry.register(new GetEventLogTool()); registry.register(new GetMcpHistoryTool()); diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/Toolsets.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/Toolsets.java index 59e27080d..ab4c0c980 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/Toolsets.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/Toolsets.java @@ -167,6 +167,7 @@ public String getDescription() "export_configuration_to_xml", "import_configuration_from_xml", "build_external_objects", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ "create_infobase", "delete_infobase", "set_infobase_credentials", "create_project", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ "get_problem_summary", "get_project_errors", "validate_xdto_package", "get_markers", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + "code_review", //$NON-NLS-1$ "get_event_log", //$NON-NLS-1$ "get_mcp_history", //$NON-NLS-1$ "list_git_branches", "switch_git_branch", "set_branch_infobase", "create_git_branch", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java new file mode 100644 index 000000000..bd28c1a6c --- /dev/null +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java @@ -0,0 +1,373 @@ +/** + * MCP Server for EDT + * Copyright (C) 2025 DitriX (https://github.com/DitriXNew) + * Licensed under AGPL-3.0-or-later + */ + +package com.ditrix.edt.mcp.server.tools.impl; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.IPath; + +import com.ditrix.edt.mcp.server.protocol.JsonSchemaBuilder; +import com.ditrix.edt.mcp.server.protocol.JsonUtils; +import com.ditrix.edt.mcp.server.protocol.McpKeys; +import com.ditrix.edt.mcp.server.protocol.ToolResult; +import com.ditrix.edt.mcp.server.protocol.jsonrpc.ToolAnnotations; +import com.ditrix.edt.mcp.server.tools.IMcpTool; +import com.ditrix.edt.mcp.server.utils.BslLsReport; +import com.ditrix.edt.mcp.server.utils.BslLsReport.Finding; +import com.ditrix.edt.mcp.server.utils.BslLsReport.Severity; +import com.ditrix.edt.mcp.server.utils.BslLsRunner; +import com.ditrix.edt.mcp.server.utils.BslModuleUtils; +import com.ditrix.edt.mcp.server.utils.MarkdownUtils; +import com.ditrix.edt.mcp.server.utils.Pagination; +import com.ditrix.edt.mcp.server.utils.ProjectContext; + +/** + * Reviews BSL code quality by running the external BSL Language Server engine over a + * project (or one module) and rendering its diagnostics as an actionable table. This + * is the delta over {@code get_project_errors}: EDT's own {@code v8-code-style} + * checks already surface there, but the engine's metrics (magic number, + * cyclomatic/cognitive complexity, method/line length, nesting, …) are not in EDT. + *

+ * The engine runs as a subprocess (see {@link BslLsRunner}); we do not implement any + * rules ourselves. Each row is a concrete defect located by {@code Module path} + + * {@code Line} — exactly what {@code read_module_source}/{@code write_module_source} + * take — so the intended loop is review → fix → re-run to verify. + */ +public class CodeReviewTool implements IMcpTool +{ + public static final String NAME = "code_review"; //$NON-NLS-1$ + + /** Accepted values of the {@code severity} minimum-severity filter. */ + static final List SEVERITY_VALUES = Arrays.asList("error", "warning", "information", "hint"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + + private static final int DEFAULT_LIMIT = 100; + private static final int MAX_LIMIT = 1000; + + @Override + public String getName() + { + return NAME; + } + + @Override + public String getDescription() + { + return "Review BSL code quality with the BSL Language Server engine: reports code-metric defects " //$NON-NLS-1$ + + "(magic number, cyclomatic/cognitive complexity, method/line length, nesting, …) that EDT's own " //$NON-NLS-1$ + + "checks do not cover. Each finding is a defect to FIX: it carries the rule, severity, Module path and " //$NON-NLS-1$ + + "Line, ready for read_module_source / write_module_source — fix each, then re-run code_review to verify. " //$NON-NLS-1$ + + "Scope the whole project or one module; filter by severity or rule. Needs the engine jar (see the guide). " //$NON-NLS-1$ + + "Full parameters and examples: call get_tool_guide('code_review')."; //$NON-NLS-1$ + } + + @Override + public String getInputSchema() + { + return JsonSchemaBuilder.object() + .stringProperty(McpKeys.PROJECT_NAME, "EDT project name to review.", true) //$NON-NLS-1$ + .stringProperty(McpKeys.MODULE_PATH, + "Optional: narrow the review to a single module, path from src/ " //$NON-NLS-1$ + + "(e.g. 'CommonModules/Calc/Module.bsl'). Omit to review the whole configuration.") //$NON-NLS-1$ + .enumProperty("severity", //$NON-NLS-1$ + "Optional: minimum severity to report (error > warning > information > hint). Omit to report all.", //$NON-NLS-1$ + "error", "warning", "information", "hint") //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + .stringProperty("rule", //$NON-NLS-1$ + "Optional: report only diagnostics whose rule id contains this substring (e.g. 'Magic', 'Complexity').") //$NON-NLS-1$ + .integerProperty(McpKeys.LIMIT, "Max findings; default 100, max 1000 (optional).") //$NON-NLS-1$ + .build(); + } + + @Override + public ToolAnnotations getAnnotations() + { + // Analysis-only: launches the engine subprocess which READS .bsl files; never mutates the EDT + // model or writes into the project. readOnly + idempotent so clients don't gate it behind a + // write-confirmation. + return new ToolAnnotations(null, Boolean.TRUE, Boolean.FALSE, Boolean.TRUE, Boolean.FALSE); + } + + @Override + public String execute(Map params) + { + String missing = JsonUtils.requireArguments(params, McpKeys.PROJECT_NAME); + if (missing != null) + { + return missing; + } + String projectName = JsonUtils.extractStringArgument(params, McpKeys.PROJECT_NAME); + String modulePath = JsonUtils.extractStringArgument(params, McpKeys.MODULE_PATH); + String severity = JsonUtils.extractStringArgument(params, "severity"); //$NON-NLS-1$ + String rule = JsonUtils.extractStringArgument(params, "rule"); //$NON-NLS-1$ + int limit = Pagination.clampLimit(JsonUtils.extractIntArgument(params, McpKeys.LIMIT, DEFAULT_LIMIT), MAX_LIMIT); + + if (severity != null && !severity.isEmpty() + && !SEVERITY_VALUES.contains(severity.toLowerCase(Locale.ROOT))) + { + return ToolResult.error("Invalid severity: '" + severity + "'. Must be one of: " //$NON-NLS-1$ //$NON-NLS-2$ + + String.join(", ", SEVERITY_VALUES)).toJson(); //$NON-NLS-1$ + } + + ProjectContext ctx = ProjectContext.of(projectName); + if (!ctx.exists()) + { + return ToolResult.error(ProjectContext.notFoundMessage(projectName)).toJson(); + } + IProject project = ctx.project(); + + IFolder srcFolder = project.getFolder("src"); //$NON-NLS-1$ + if (srcFolder.getLocation() == null || !srcFolder.getLocation().toFile().isDirectory()) + { + return ToolResult.error("Project '" + projectName + "' has no src/ folder to review.").toJson(); //$NON-NLS-1$ //$NON-NLS-2$ + } + File srcRoot = srcFolder.getLocation().toFile(); + + // Scope: whole src, or the folder of a single requested module (findings are still + // filtered to that exact module below). + File scopeDir = srcRoot; + String targetAbsPath = null; + if (modulePath != null && !modulePath.isEmpty()) + { + IFile moduleFile = BslModuleUtils.resolveModuleFile(project, modulePath); + // resolveModuleFile hands back a (possibly non-existent) handle, so check the file is + // really on disk here — otherwise a bad path leaks out later as the runner's internal + // "source directory does not exist" instead of an actionable module-not-found error. + File moduleOsFile = moduleFile == null || moduleFile.getLocation() == null + ? null : moduleFile.getLocation().toFile(); + if (moduleOsFile == null || !moduleOsFile.isFile()) + { + return ToolResult.error("Module not found: src/" + modulePath //$NON-NLS-1$ + + ". Pass a path from src/, e.g. 'CommonModules/Calc/Module.bsl'.").toJson(); //$NON-NLS-1$ + } + targetAbsPath = normalize(moduleOsFile.getAbsolutePath()); + scopeDir = moduleOsFile.getParentFile(); + } + + BslLsRunner.Request request = new BslLsRunner.Request(scopeDir).configFile(projectConfig(srcRoot, project)); + BslLsRunner.Result result = BslLsRunner.run(request); + if (!result.ok()) + { + return ToolResult.error(result.errorMessage()).toJson(); + } + + return render(result.report(), projectName, modulePath, srcRoot, targetAbsPath, severity, rule, limit); + } + + /** + * Resolves the project's own {@code .bsl-language-server.json} if present; otherwise + * {@code null}, letting {@link BslLsRunner} fall back to the engine-home config or the + * engine defaults. + */ + private static File projectConfig(File srcRoot, IProject project) + { + // Config conventionally sits at the project root (parent of src/). + File projectRoot = srcRoot.getParentFile(); + if (projectRoot != null) + { + File cfg = new File(projectRoot, ".bsl-language-server.json"); //$NON-NLS-1$ + if (cfg.isFile()) + { + return cfg; + } + } + IPath loc = project.getLocation(); + if (loc != null) + { + File cfg = new File(loc.toFile(), ".bsl-language-server.json"); //$NON-NLS-1$ + if (cfg.isFile()) + { + return cfg; + } + } + return null; + } + + /** + * Renders the report as an actionable Markdown table. Package-private and static so + * it is unit-testable against a {@link BslLsReport} built from a captured JSON sample + * without spawning the engine. + * + * @param report the parsed engine report + * @param projectName the reviewed project + * @param modulePath the requested single-module scope, or {@code null} for whole-project + * @param srcRoot the project's {@code src} directory (to relativize paths to {@code Module path}) + * @param targetAbsPath when scoped to one module, its normalized absolute path (findings are + * filtered to it); {@code null} for whole-project + * @param severityMin the minimum-severity filter name, or {@code null} for all + * @param rule the rule-substring filter, or {@code null} for all + * @param limit the maximum number of rows to render + * @return the Markdown result + */ + static String render(BslLsReport report, String projectName, String modulePath, File srcRoot, + String targetAbsPath, String severityMin, String rule, int limit) + { + int minRank = severityMin == null || severityMin.isEmpty() ? Integer.MIN_VALUE + : rank(Severity.valueOf(severityMin.toUpperCase(Locale.ROOT))); + String ruleNeedle = rule == null ? null : rule.toLowerCase(Locale.ROOT); + + List filtered = new ArrayList<>(); + for (Finding f : report.findings()) + { + if (targetAbsPath != null && !targetAbsPath.equals(normalize(f.path()))) + { + continue; + } + if (rank(f.severity()) < minRank) + { + continue; + } + if (ruleNeedle != null && (f.code() == null || !f.code().toLowerCase(Locale.ROOT).contains(ruleNeedle))) + { + continue; + } + filtered.add(f); + } + filtered.sort(Comparator.comparingInt((Finding f) -> rank(f.severity())).reversed() + .thenComparing(f -> modulePathOf(srcRoot, f.path())) + .thenComparingInt(Finding::line)); + + StringBuilder md = new StringBuilder(); + String scope = modulePath == null || modulePath.isEmpty() ? projectName : projectName + " / " + modulePath; //$NON-NLS-1$ + md.append("# Code review — ").append(MarkdownUtils.escapeForTable(scope)).append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$ + + md.append("**").append(report.total()).append("** finding(s): ") //$NON-NLS-1$ //$NON-NLS-2$ + .append(report.count(Severity.ERROR)).append(" error, ") //$NON-NLS-1$ + .append(report.count(Severity.WARNING)).append(" warning, ") //$NON-NLS-1$ + .append(report.count(Severity.INFORMATION)).append(" information, ") //$NON-NLS-1$ + .append(report.count(Severity.HINT)).append(" hint.\n\n"); //$NON-NLS-1$ + + if (report.total() == 0) + { + md.append("No BSL code-quality issues found. "); //$NON-NLS-1$ + md.append("(If you expected findings, confirm the engine jar and configuration — see get_tool_guide('code_review').)\n"); //$NON-NLS-1$ + return md.toString(); + } + + md.append("Each row is a code defect. Fix it at its `Module path` + `Line` via write_module_source " //$NON-NLS-1$ + + "(inspect with read_module_source), then re-run code_review to verify. Mechanical issues " //$NON-NLS-1$ + + "(e.g. MagicNumber) can be fixed directly; complexity/nesting may need a judged refactor.\n\n"); //$NON-NLS-1$ + + if (filtered.isEmpty()) + { + md.append("_No findings match the current filters._\n"); //$NON-NLS-1$ + return md.toString(); + } + + boolean capped = filtered.size() > limit; + List shown = capped ? filtered.subList(0, limit) : filtered; + + md.append(MarkdownUtils.tableHeader("Severity", "Rule", "Module path", "Line", "Message", "Docs")); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$ + for (Finding f : shown) + { + md.append(MarkdownUtils.tableRow( + label(f.severity()), + nullToEmpty(f.code()), + modulePathOf(srcRoot, f.path()), + String.valueOf(f.line()), + nullToEmpty(f.message()), + nullToEmpty(f.href()))); + } + + if (capped) + { + md.append('\n').append(Pagination.limitReachedNotice(limit)); + } + return md.toString(); + } + + /** Severity importance rank; higher is more severe (Error highest, Hint lowest). */ + private static int rank(Severity s) + { + switch (s) + { + case ERROR: + return 3; + case WARNING: + return 2; + case INFORMATION: + return 1; + case HINT: + default: + return 0; + } + } + + private static String label(Severity s) + { + switch (s) + { + case ERROR: + return "Error"; //$NON-NLS-1$ + case WARNING: + return "Warning"; //$NON-NLS-1$ + case INFORMATION: + return "Information"; //$NON-NLS-1$ + case HINT: + default: + return "Hint"; //$NON-NLS-1$ + } + } + + /** + * Relativizes an absolute finding path to the project {@code src} root, yielding the + * {@code modulePath} form ({@code CommonModules/Calc/Module.bsl}) that + * read/write_module_source accept. Falls back to the absolute path when the finding is + * not under {@code src}. + */ + private static String modulePathOf(File srcRoot, String absPath) + { + if (absPath == null) + { + return ""; //$NON-NLS-1$ + } + try + { + Path root = srcRoot.toPath().toAbsolutePath().normalize(); + Path p = Paths.get(absPath).toAbsolutePath().normalize(); + if (p.startsWith(root)) + { + return root.relativize(p).toString().replace('\\', '/'); + } + } + catch (RuntimeException e) + { + // fall through to the absolute path + } + return absPath; + } + + private static String normalize(String path) + { + if (path == null) + { + return null; + } + try + { + return Paths.get(path).toAbsolutePath().normalize().toString(); + } + catch (RuntimeException e) + { + return path; + } + } + + private static String nullToEmpty(String s) + { + return s == null ? "" : s; //$NON-NLS-1$ + } +} diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsReport.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsReport.java new file mode 100644 index 000000000..f97fcf9ae --- /dev/null +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsReport.java @@ -0,0 +1,459 @@ +/** + * MCP Server for EDT + * Copyright (C) 2025 DitriX (https://github.com/DitriXNew) + * Licensed under AGPL-3.0-or-later + */ + +package com.ditrix.edt.mcp.server.utils; + +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Parsed model of a BSL Language Server JSON report (the {@code --reporter json} + * output, one {@code bsl-json.json}), plus the {@link #parse(String)} that builds + * it. Kept free of any process/IO so it is unit-testable against a captured report + * string (see {@code BslLsReportTest}); the subprocess side lives in + * {@link BslLsRunner}. + *

+ * The engine emits diagnostics with LSP conventions: 0-based line/character + * and a {@code file://} URI that may carry {@code ../} segments relative to the + * process working directory. This model normalizes both — lines/columns are + * converted to 1-based (to match the rest of the tools and BSL AST helpers) + * and the path is collapsed to an absolute filesystem path. Diagnostic messages are + * left verbatim (they are engine data, localized by the engine's configured + * {@code diagnosticLanguage}). + */ +public final class BslLsReport +{ + /** LSP diagnostic severity as emitted by BSL LS ({@code Error/Warning/Information/Hint}). */ + public enum Severity + { + ERROR, WARNING, INFORMATION, HINT; + + /** + * Maps the engine's severity token to this enum; anything unrecognized (or + * {@code null}) falls back to {@link #INFORMATION} so an unexpected token never + * throws. + * + * @param token the BSL LS {@code severity} string + * @return the matching severity (never {@code null}) + */ + public static Severity fromToken(String token) + { + if (token == null) + { + return INFORMATION; + } + switch (token) + { + case "Error": //$NON-NLS-1$ + return ERROR; + case "Warning": //$NON-NLS-1$ + return WARNING; + case "Hint": //$NON-NLS-1$ + return HINT; + case "Information": //$NON-NLS-1$ + default: + return INFORMATION; + } + } + } + + /** One diagnostic (a defect to review/fix), located and typed. */ + public static final class Finding + { + private final String mdoRef; + private final String path; + private final int line; + private final int column; + private final String code; + private final Severity severity; + private final String message; + private final String href; + private final List tags; + + Finding(String mdoRef, String path, int line, int column, String code, Severity severity, + String message, String href, List tags) + { + this.mdoRef = mdoRef; + this.path = path; + this.line = line; + this.column = column; + this.code = code; + this.severity = severity; + this.message = message; + this.href = href; + this.tags = tags == null ? Collections.emptyList() : Collections.unmodifiableList(tags); + } + + /** @return the metadata reference of the host module, e.g. {@code CommonModule.Calc} (may be {@code null}). */ + public String mdoRef() + { + return mdoRef; + } + + /** @return the absolute, normalized filesystem path of the module (may be {@code null}). */ + public String path() + { + return path; + } + + /** @return 1-based line number of the diagnostic. */ + public int line() + { + return line; + } + + /** @return 1-based column number of the diagnostic start. */ + public int column() + { + return column; + } + + /** @return the rule identifier, e.g. {@code MagicNumber} (may be {@code null}). */ + public String code() + { + return code; + } + + /** @return the diagnostic severity (never {@code null}). */ + public Severity severity() + { + return severity; + } + + /** @return the diagnostic message, verbatim from the engine (may be {@code null}). */ + public String message() + { + return message; + } + + /** @return the rule's documentation URL, usable as a remediation hint (may be {@code null}). */ + public String href() + { + return href; + } + + /** @return the LSP tags ({@code Unnecessary}/{@code Deprecated}); never {@code null}. */ + public List tags() + { + return tags; + } + } + + /** Per-module code metrics (the {@code metrics} block of a file entry). */ + public static final class FileMetrics + { + private final String mdoRef; + private final String path; + private final int cyclomaticComplexity; + private final int cognitiveComplexity; + private final int ncloc; + private final int statements; + private final int procedures; + private final int functions; + + FileMetrics(String mdoRef, String path, int cyclomaticComplexity, int cognitiveComplexity, int ncloc, + int statements, int procedures, int functions) + { + this.mdoRef = mdoRef; + this.path = path; + this.cyclomaticComplexity = cyclomaticComplexity; + this.cognitiveComplexity = cognitiveComplexity; + this.ncloc = ncloc; + this.statements = statements; + this.procedures = procedures; + this.functions = functions; + } + + /** @return the metadata reference of the module. */ + public String mdoRef() + { + return mdoRef; + } + + /** @return the absolute, normalized filesystem path of the module. */ + public String path() + { + return path; + } + + /** @return cyclomatic complexity of the module. */ + public int cyclomaticComplexity() + { + return cyclomaticComplexity; + } + + /** @return cognitive complexity of the module. */ + public int cognitiveComplexity() + { + return cognitiveComplexity; + } + + /** @return non-comment lines of code. */ + public int ncloc() + { + return ncloc; + } + + /** @return number of statements. */ + public int statements() + { + return statements; + } + + /** @return number of procedures. */ + public int procedures() + { + return procedures; + } + + /** @return number of functions. */ + public int functions() + { + return functions; + } + } + + private final List findings; + private final List metrics; + private final Map severityCounts; + + private BslLsReport(List findings, List metrics) + { + this.findings = Collections.unmodifiableList(findings); + this.metrics = Collections.unmodifiableList(metrics); + Map counts = new LinkedHashMap<>(); + for (Severity s : Severity.values()) + { + counts.put(s, 0); + } + for (Finding f : findings) + { + counts.merge(f.severity(), 1, Integer::sum); + } + this.severityCounts = Collections.unmodifiableMap(counts); + } + + /** @return all diagnostics across every analyzed module (never {@code null}). */ + public List findings() + { + return findings; + } + + /** @return per-module metrics (never {@code null}). */ + public List metrics() + { + return metrics; + } + + /** @return total number of diagnostics. */ + public int total() + { + return findings.size(); + } + + /** + * @param severity the severity to count + * @return how many findings carry that severity + */ + public int count(Severity severity) + { + return severityCounts.getOrDefault(severity, 0); + } + + /** + * Parses a BSL LS JSON report string into this model. Tolerant of missing/null + * fields (a malformed entry contributes nothing rather than throwing); the only + * hard failure is a string that is not a JSON object. + * + * @param json the {@code bsl-json.json} content + * @return the parsed report (never {@code null}; may be empty) + * @throws IllegalArgumentException if {@code json} is not a JSON object + */ + public static BslLsReport parse(String json) + { + List findings = new ArrayList<>(); + List metrics = new ArrayList<>(); + + JsonElement rootEl; + try + { + rootEl = JsonParser.parseString(json == null ? "" : json); //$NON-NLS-1$ + } + catch (RuntimeException e) + { + throw new IllegalArgumentException("BSL LS report is not valid JSON", e); //$NON-NLS-1$ + } + if (rootEl == null || !rootEl.isJsonObject()) + { + throw new IllegalArgumentException("BSL LS report is not a JSON object"); //$NON-NLS-1$ + } + JsonObject root = rootEl.getAsJsonObject(); + JsonArray fileInfos = asArray(root, "fileinfos"); //$NON-NLS-1$ + if (fileInfos == null) + { + return new BslLsReport(findings, metrics); + } + + for (JsonElement fiEl : fileInfos) + { + if (fiEl == null || !fiEl.isJsonObject()) + { + continue; + } + JsonObject fi = fiEl.getAsJsonObject(); + String mdoRef = optString(fi, "mdoRef"); //$NON-NLS-1$ + String path = normalizePath(optString(fi, "path")); //$NON-NLS-1$ + + JsonArray diags = asArray(fi, "diagnostics"); //$NON-NLS-1$ + if (diags != null) + { + for (JsonElement dEl : diags) + { + if (dEl == null || !dEl.isJsonObject()) + { + continue; + } + findings.add(toFinding(dEl.getAsJsonObject(), mdoRef, path)); + } + } + + FileMetrics m = toMetrics(fi, mdoRef, path); + if (m != null) + { + metrics.add(m); + } + } + return new BslLsReport(findings, metrics); + } + + private static Finding toFinding(JsonObject d, String mdoRef, String path) + { + String code = optString(d, "code"); //$NON-NLS-1$ + String message = optString(d, "message"); //$NON-NLS-1$ + Severity severity = Severity.fromToken(optString(d, "severity")); //$NON-NLS-1$ + + // range.start.{line,character} are 0-based (LSP) -> convert to 1-based. + int line = 1; + int column = 1; + JsonObject range = optObject(d, "range"); //$NON-NLS-1$ + if (range != null) + { + JsonObject start = optObject(range, "start"); //$NON-NLS-1$ + if (start != null) + { + line = optInt(start, "line", 0) + 1; //$NON-NLS-1$ + column = optInt(start, "character", 0) + 1; //$NON-NLS-1$ + } + } + + String href = null; + JsonObject codeDescription = optObject(d, "codeDescription"); //$NON-NLS-1$ + if (codeDescription != null) + { + href = optString(codeDescription, "href"); //$NON-NLS-1$ + } + + List tags = new ArrayList<>(); + JsonArray tagArr = asArray(d, "tags"); //$NON-NLS-1$ + if (tagArr != null) + { + for (JsonElement t : tagArr) + { + if (t != null && t.isJsonPrimitive()) + { + tags.add(t.getAsString()); + } + } + } + + return new Finding(mdoRef, path, line, column, code, severity, message, href, tags); + } + + private static FileMetrics toMetrics(JsonObject fi, String mdoRef, String path) + { + JsonObject m = optObject(fi, "metrics"); //$NON-NLS-1$ + if (m == null) + { + return null; + } + return new FileMetrics(mdoRef, path, + optInt(m, "cyclomaticComplexity", 0), //$NON-NLS-1$ + optInt(m, "cognitiveComplexity", 0), //$NON-NLS-1$ + optInt(m, "ncloc", 0), //$NON-NLS-1$ + optInt(m, "statements", 0), //$NON-NLS-1$ + optInt(m, "procedures", 0), //$NON-NLS-1$ + optInt(m, "functions", 0)); //$NON-NLS-1$ + } + + /** + * Collapses a {@code file://} URI (possibly carrying {@code ../} segments relative + * to the engine's working directory) to an absolute, normalized filesystem path. + * Falls back to the raw value on any parse failure so a client always sees + * something locatable. + */ + private static String normalizePath(String raw) + { + if (raw == null || raw.isEmpty()) + { + return raw; + } + try + { + if (raw.startsWith("file:")) //$NON-NLS-1$ + { + Path p = Paths.get(new URI(raw)).normalize().toAbsolutePath(); + return p.toString(); + } + return Paths.get(raw).normalize().toAbsolutePath().toString(); + } + catch (RuntimeException | java.net.URISyntaxException e) + { + return raw; + } + } + + private static JsonArray asArray(JsonObject o, String key) + { + JsonElement el = o.get(key); + return el != null && el.isJsonArray() ? el.getAsJsonArray() : null; + } + + private static JsonObject optObject(JsonObject o, String key) + { + JsonElement el = o.get(key); + return el != null && el.isJsonObject() ? el.getAsJsonObject() : null; + } + + private static String optString(JsonObject o, String key) + { + JsonElement el = o.get(key); + return el != null && el.isJsonPrimitive() ? el.getAsString() : null; + } + + private static int optInt(JsonObject o, String key, int fallback) + { + JsonElement el = o.get(key); + try + { + return el != null && el.isJsonPrimitive() ? el.getAsInt() : fallback; + } + catch (NumberFormatException e) + { + return fallback; + } + } +} diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java new file mode 100644 index 000000000..75e5a61d7 --- /dev/null +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java @@ -0,0 +1,519 @@ +/** + * MCP Server for EDT + * Copyright (C) 2025 DitriX (https://github.com/DitriXNew) + * Licensed under AGPL-3.0-or-later + */ + +package com.ditrix.edt.mcp.server.utils; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Runs the external BSL Language Server engine ({@code bsl-language-server-*-exec.jar}) + * as a subprocess in analyze mode and returns its parsed JSON report. This is the + * process/IO half of {@code code_review}; the pure model + JSON parsing live in + * {@link BslLsReport}. + *

+ * Why a subprocess and not in-process: the engine is a Spring-Boot fat jar + * (its own classloader graph); running it inside the EDT OSGi runtime risks + * classloader conflicts and buys nothing. We invoke the stable CLI + * ({@code --analyze --reporter json}) exactly as the reference plugin does. + *

+ * Two independent knobs (neither requires rebuilding the plugin): + *

    + *
  • the engine jar — {@link #ENV_JAR} / an explicit override / a default folder;
  • + *
  • the Java used to launch it — {@link #ENV_JAVA} / an explicit override, falling + * back to the JRE running EDT ({@code java.home}). The {@code 1.x} engine line needs + * Java 21; {@code 0.28.x} runs on Java 17.
  • + *
+ * The configuration of which checks run is the engine's own + * {@code .bsl-language-server.json} (see {@link Request#configFile}). + */ +public final class BslLsRunner +{ + /** Env var pointing at the engine {@code exec.jar}. */ + public static final String ENV_JAR = "EDT_MCP_BSL_LS_JAR"; //$NON-NLS-1$ + + /** Env var pointing at the {@code java(.exe)} used to launch the engine. */ + public static final String ENV_JAVA = "EDT_MCP_BSL_LS_JAVA"; //$NON-NLS-1$ + + /** Releases page cited in the not-found error so a client can self-serve. */ + public static final String RELEASES_URL = "https://github.com/1c-syntax/bsl-language-server/releases"; //$NON-NLS-1$ + + private static final String REPORT_FILE = "bsl-json.json"; //$NON-NLS-1$ + private static final int DEFAULT_TIMEOUT_SECONDS = 180; + + private BslLsRunner() + { + } + + /** + * Inputs for one analyze run. Only {@link #srcDir} is required; the rest resolve + * from env/defaults when left {@code null}. + */ + public static final class Request + { + private final File srcDir; + private File configFile; + private File jarOverride; + private File javaOverride; + private int timeoutSeconds = DEFAULT_TIMEOUT_SECONDS; + + /** + * @param srcDir the directory to analyze (a project {@code src} folder or a + * narrower subtree); must exist + */ + public Request(File srcDir) + { + this.srcDir = srcDir; + } + + /** + * @param file the project's {@code .bsl-language-server.json}; when {@code null} + * or absent the engine-home config (next to the jar) is used, else + * engine defaults + * @return this request + */ + public Request configFile(File file) + { + this.configFile = file; + return this; + } + + /** + * @param jar an explicit engine jar path, taking precedence over {@link #ENV_JAR} + * @return this request + */ + public Request jarOverride(File jar) + { + this.jarOverride = jar; + return this; + } + + /** + * @param java an explicit {@code java(.exe)} path, taking precedence over + * {@link #ENV_JAVA} + * @return this request + */ + public Request javaOverride(File java) + { + this.javaOverride = java; + return this; + } + + /** + * @param seconds the subprocess timeout; non-positive resets to the default + * @return this request + */ + public Request timeoutSeconds(int seconds) + { + this.timeoutSeconds = seconds > 0 ? seconds : DEFAULT_TIMEOUT_SECONDS; + return this; + } + } + + /** Outcome of a run: either a parsed {@link BslLsReport} or an actionable error message. */ + public static final class Result + { + private final BslLsReport report; + private final String errorMessage; + + private Result(BslLsReport report, String errorMessage) + { + this.report = report; + this.errorMessage = errorMessage; + } + + static Result ok(BslLsReport report) + { + return new Result(report, null); + } + + static Result error(String message) + { + return new Result(null, message); + } + + /** @return {@code true} when the engine ran and its report parsed. */ + public boolean ok() + { + return errorMessage == null; + } + + /** @return the parsed report, or {@code null} on failure. */ + public BslLsReport report() + { + return report; + } + + /** @return the actionable failure message, or {@code null} on success. */ + public String errorMessage() + { + return errorMessage; + } + } + + /** + * Resolves the jar and Java, launches the engine on {@code request.srcDir}, and + * parses its JSON report. Never throws for an operational problem (missing + * jar/Java, non-zero exit, timeout, unreadable report) — those come back as + * {@link Result#error(String)} with an actionable message. + * + * @param request the run inputs (must be non-{@code null} with an existing srcDir) + * @return the run outcome (never {@code null}) + */ + public static Result run(Request request) + { + if (request == null || request.srcDir == null) + { + return Result.error("Internal error: no source directory provided to the BSL Language Server."); //$NON-NLS-1$ + } + if (!request.srcDir.isDirectory()) + { + return Result.error("Source directory does not exist: " + request.srcDir); //$NON-NLS-1$ + } + + File jar = resolveJar(request.jarOverride); + if (jar == null) + { + return Result.error(jarNotFoundMessage()); + } + File java = resolveJava(request.javaOverride); + if (java == null) + { + return Result.error(javaNotFoundMessage()); + } + File config = resolveConfig(request.configFile, jar); + + Path outputDir; + try + { + outputDir = Files.createTempDirectory("bslls"); //$NON-NLS-1$ + } + catch (IOException e) + { + return Result.error("Could not create a temporary output directory for the BSL Language Server: " //$NON-NLS-1$ + + e.getMessage()); + } + + try + { + return execute(java, jar, config, request, outputDir); + } + finally + { + deleteQuietly(outputDir); + } + } + + private static Result execute(File java, File jar, File config, Request request, Path outputDir) + { + List command = new ArrayList<>(); + command.add(java.getAbsolutePath()); + command.add("-Dfile.encoding=UTF-8"); //$NON-NLS-1$ + command.add("-jar"); //$NON-NLS-1$ + command.add(jar.getAbsolutePath()); + command.add("--analyze"); //$NON-NLS-1$ + command.add("--srcDir"); //$NON-NLS-1$ + command.add(request.srcDir.getAbsolutePath()); + command.add("--outputDir"); //$NON-NLS-1$ + command.add(outputDir.toString()); + command.add("--reporter"); //$NON-NLS-1$ + command.add("json"); //$NON-NLS-1$ + if (config != null) + { + command.add("--configuration"); //$NON-NLS-1$ + command.add(config.getAbsolutePath()); + } + + ProcessBuilder pb = new ProcessBuilder(command); + // Working directory MUST share a filesystem root with the analyzed sources: the engine + // relativizes each source file against the process CWD (getFileInfoFromFile), which throws + // "'other' has different root" when CWD and the sources are on different drives (e.g. a temp + // dir on C: vs a project on D:, common on Windows). The scope dir is always under the project, + // so use it as CWD; the outputDir stays an absolute path and may live on any drive. + pb.directory(request.srcDir); + pb.redirectErrorStream(true); + + Process process; + try + { + process = pb.start(); + } + catch (IOException e) + { + return Result.error("Failed to launch the BSL Language Server (" + java.getAbsolutePath() //$NON-NLS-1$ + + "): " + e.getMessage()); //$NON-NLS-1$ + } + + StringBuilder captured = new StringBuilder(); + Thread drain = drainAsync(process, captured); + + boolean finished; + try + { + finished = process.waitFor(request.timeoutSeconds, TimeUnit.SECONDS); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + return Result.error("Interrupted while waiting for the BSL Language Server."); //$NON-NLS-1$ + } + + if (!finished) + { + process.destroyForcibly(); + join(drain); + return Result.error("BSL Language Server timed out after " + request.timeoutSeconds //$NON-NLS-1$ + + "s. Narrow the scope or raise the timeout."); //$NON-NLS-1$ + } + join(drain); + + int exit = process.exitValue(); + Path reportPath = outputDir.resolve(REPORT_FILE); + if (!Files.isRegularFile(reportPath)) + { + return Result.error("BSL Language Server produced no JSON report (exit " + exit + "). " //$NON-NLS-1$ //$NON-NLS-2$ + + "Engine output: " + tail(captured.toString())); //$NON-NLS-1$ + } + + String json; + try + { + json = new String(Files.readAllBytes(reportPath), StandardCharsets.UTF_8); + } + catch (IOException e) + { + return Result.error("Could not read the BSL Language Server report: " + e.getMessage()); //$NON-NLS-1$ + } + + try + { + return Result.ok(BslLsReport.parse(json)); + } + catch (IllegalArgumentException e) + { + return Result.error("BSL Language Server report was not parseable: " + e.getMessage()); //$NON-NLS-1$ + } + } + + /** + * Resolves the engine jar: explicit override, then {@link #ENV_JAR}, then a scan of + * a default folder ({@code /bsl-language-server}). Returns the first + * existing jar, or {@code null} when none is found. + */ + static File resolveJar(File override) + { + if (isFile(override)) + { + return override; + } + File fromEnv = fileFromEnv(ENV_JAR); + if (isFile(fromEnv)) + { + return fromEnv; + } + File defaultDir = new File(System.getProperty("user.home", ""), "bsl-language-server"); //$NON-NLS-1$ //$NON-NLS-2$ + File scanned = scanForExecJar(defaultDir); + return scanned; + } + + private static File scanForExecJar(File dir) + { + if (dir == null || !dir.isDirectory()) + { + return null; + } + File[] jars = dir.listFiles((d, name) -> name.startsWith("bsl-language-server") //$NON-NLS-1$ + && name.endsWith("-exec.jar")); //$NON-NLS-1$ + if (jars == null || jars.length == 0) + { + return null; + } + // Prefer the lexicographically largest name (roughly the newest version). + File best = jars[0]; + for (File j : jars) + { + if (j.getName().compareTo(best.getName()) > 0) + { + best = j; + } + } + return best; + } + + /** + * Resolves the Java launcher: explicit override, then {@link #ENV_JAVA}, then the + * JRE running EDT ({@code java.home}). Returns {@code null} only if none resolves to + * an existing file (practically never — {@code java.home} is always set). + */ + static File resolveJava(File override) + { + if (isFile(override)) + { + return override; + } + File fromEnv = fileFromEnv(ENV_JAVA); + if (isFile(fromEnv)) + { + return fromEnv; + } + String javaHome = System.getProperty("java.home"); //$NON-NLS-1$ + if (javaHome != null && !javaHome.isEmpty()) + { + File bin = new File(javaHome, "bin"); //$NON-NLS-1$ + File exe = new File(bin, isWindows() ? "java.exe" : "java"); //$NON-NLS-1$ //$NON-NLS-2$ + if (exe.isFile()) + { + return exe; + } + } + return null; + } + + /** + * Resolves the engine configuration file: the project's own + * {@code .bsl-language-server.json} (if present), else the one sitting next to the + * jar (engine home), else {@code null} (engine defaults). + */ + static File resolveConfig(File projectConfig, File jar) + { + if (isFile(projectConfig)) + { + return projectConfig; + } + if (jar != null && jar.getParentFile() != null) + { + File engineHomeConfig = new File(jar.getParentFile(), ".bsl-language-server.json"); //$NON-NLS-1$ + if (engineHomeConfig.isFile()) + { + return engineHomeConfig; + } + } + return null; + } + + private static String jarNotFoundMessage() + { + return "BSL Language Server engine not found. Set " + ENV_JAR //$NON-NLS-1$ + + " to the path of bsl-language-server--exec.jar, or place it in " //$NON-NLS-1$ + + "/bsl-language-server. Download it from " + RELEASES_URL //$NON-NLS-1$ + + " (the 1.x line needs Java 21; the 0.28.x line runs on Java 17)."; //$NON-NLS-1$ + } + + private static String javaNotFoundMessage() + { + return "No Java runtime found to launch the BSL Language Server. Set " + ENV_JAVA //$NON-NLS-1$ + + " to a java executable (Java 21+ for the 1.x engine, Java 17 for 0.28.x)."; //$NON-NLS-1$ + } + + private static Thread drainAsync(Process process, StringBuilder sink) + { + Thread t = new Thread(() -> { + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) + { + String line; + while ((line = reader.readLine()) != null) + { + synchronized (sink) + { + sink.append(line).append('\n'); + } + } + } + catch (IOException e) + { + // Stream closed on process exit/kill — nothing actionable. + } + }, "bslls-drain"); //$NON-NLS-1$ + t.setDaemon(true); + t.start(); + return t; + } + + private static void join(Thread t) + { + try + { + t.join(2000); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + } + + /** Returns the last portion of engine output, so an error message stays bounded. */ + private static String tail(String s) + { + if (s == null) + { + return ""; //$NON-NLS-1$ + } + String trimmed = s.trim(); + int max = 600; + if (trimmed.length() <= max) + { + return trimmed; + } + return "…" + trimmed.substring(trimmed.length() - max); //$NON-NLS-1$ + } + + private static void deleteQuietly(Path dir) + { + if (dir == null) + { + return; + } + try + { + Files.walk(dir) + .sorted((a, b) -> b.getNameCount() - a.getNameCount()) + .forEach(p -> { + try + { + Files.deleteIfExists(p); + } + catch (IOException ignored) + { + // best effort + } + }); + } + catch (IOException ignored) + { + // best effort + } + } + + private static File fileFromEnv(String var) + { + String value = System.getenv(var); + if (value == null || value.trim().isEmpty()) + { + return null; + } + return new File(value.trim()); + } + + private static boolean isFile(File f) + { + return f != null && f.isFile(); + } + + private static boolean isWindows() + { + return System.getProperty("os.name", "").toLowerCase().contains("win"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + } +} diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java new file mode 100644 index 000000000..2b487dc80 --- /dev/null +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java @@ -0,0 +1,196 @@ +/** + * MCP Server for EDT - Tests + * Copyright (C) 2025 DitriX (https://github.com/DitriXNew) + * Licensed under AGPL-3.0-or-later + */ + +package com.ditrix.edt.mcp.server.tools.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import com.ditrix.edt.mcp.server.tools.IMcpTool.ResponseType; +import com.ditrix.edt.mcp.server.utils.BslLsReport; + +/** + * Tests for {@link CodeReviewTool}. + *

+ * Covers the tool contract (name, MARKDOWN response type, guide pointer, schema↔param + * parity, required array, read-only annotation), the Display-free argument validation + * that returns a {@code ToolResult.error} JSON BEFORE any workspace access, and the + * Markdown rendering — the latter exercised directly through + * {@link CodeReviewTool#render} on a {@link BslLsReport} built from a captured engine + * sample, so the formatting (summary counts, severity/rule filtering, the fix-and-verify + * steering, the clean-project message) is verified without spawning the engine. The live + * subprocess path (real jar + Java 21) is covered by the E2E suite. + */ +public class CodeReviewToolTest +{ + /** Two findings (MagicNumber = Information, UnusedLocalVariable = Warning) + one clean file. */ + private static final String SAMPLE = "{" + + "\"fileinfos\":[" + + " {\"path\":\"file:///C:/proj/src/CommonModules/Calc/Module.bsl\",\"mdoRef\":\"CommonModule.Calc\"," + + " \"diagnostics\":[" + + " {\"code\":\"MagicNumber\"," + + " \"codeDescription\":{\"href\":\"https://1c-syntax.github.io/bsl-language-server/diagnostics/MagicNumber\"}," + + " \"message\":\"Assign this magic number to a constant\"," + + " \"range\":{\"start\":{\"character\":20,\"line\":5},\"end\":{\"character\":21,\"line\":5}}," + + " \"severity\":\"Information\",\"tags\":[]}," + + " {\"code\":\"UnusedLocalVariable\"," + + " \"codeDescription\":{\"href\":\"https://1c-syntax.github.io/bsl-language-server/diagnostics/UnusedLocalVariable\"}," + + " \"message\":\"Remove unused variable\"," + + " \"range\":{\"start\":{\"character\":1,\"line\":5},\"end\":{\"character\":10,\"line\":5}}," + + " \"severity\":\"Warning\",\"tags\":[\"Unnecessary\"]}" + + " ],\"metrics\":{\"cyclomaticComplexity\":2}}" + + "],\"sourceDir\":\"C:/proj/src\"}"; + + private static final String EMPTY = "{\"fileinfos\":[]}"; + + @Test + public void testName() + { + assertEquals("code_review", new CodeReviewTool().getName()); //$NON-NLS-1$ + assertEquals(CodeReviewTool.NAME, new CodeReviewTool().getName()); + } + + @Test + public void testResponseTypeMarkdown() + { + assertEquals(ResponseType.MARKDOWN, new CodeReviewTool().getResponseType()); + } + + @Test + public void testDescriptionPointsAtGuide() + { + String desc = new CodeReviewTool().getDescription(); + assertNotNull(desc); + assertTrue(desc.contains("get_tool_guide('code_review')")); //$NON-NLS-1$ + } + + @Test + public void testSchemaDeclaresParameters() + { + String schema = new CodeReviewTool().getInputSchema(); + assertNotNull(schema); + assertTrue(schema.contains("\"projectName\"")); //$NON-NLS-1$ + assertTrue(schema.contains("\"modulePath\"")); //$NON-NLS-1$ + assertTrue(schema.contains("\"severity\"")); //$NON-NLS-1$ + assertTrue(schema.contains("\"rule\"")); //$NON-NLS-1$ + assertTrue(schema.contains("\"limit\"")); //$NON-NLS-1$ + } + + @Test + public void testRequiredParameters() + { + String schema = new CodeReviewTool().getInputSchema(); + int requiredIdx = schema.indexOf("\"required\""); //$NON-NLS-1$ + assertTrue("schema must declare a required array", requiredIdx >= 0); //$NON-NLS-1$ + int open = schema.indexOf('[', requiredIdx); + int close = schema.indexOf(']', open); + assertTrue("required array must be well-formed", open >= 0 && close > open); //$NON-NLS-1$ + String requiredBlock = schema.substring(open, close); + assertTrue("projectName must be required", requiredBlock.contains("\"projectName\"")); //$NON-NLS-1$ //$NON-NLS-2$ + assertFalse("modulePath must NOT be required", requiredBlock.contains("\"modulePath\"")); //$NON-NLS-1$ //$NON-NLS-2$ + assertFalse("severity must NOT be required", requiredBlock.contains("\"severity\"")); //$NON-NLS-1$ //$NON-NLS-2$ + } + + @Test + public void testReadOnlyAnnotation() + { + assertEquals(Boolean.TRUE, new CodeReviewTool().getAnnotations().getReadOnlyHint()); + } + + @Test + public void testGuideHasEngineSetupDetail() + { + String guide = new CodeReviewTool().getGuide(); + assertNotNull(guide); + // The one-time engine setup detail lives in the guide, not the slim description. + assertTrue(guide.contains("EDT_MCP_BSL_LS_JAR")); //$NON-NLS-1$ + assertTrue(guide.contains("EDT_MCP_BSL_LS_JAVA")); //$NON-NLS-1$ + assertTrue(guide.contains(".bsl-language-server.json")); //$NON-NLS-1$ + assertTrue(guide.contains("write_module_source")); //$NON-NLS-1$ + } + + // ==================== Argument validation (returns before any workspace access) ==================== + + @Test + public void testMissingProjectName() + { + String result = new CodeReviewTool().execute(new HashMap<>()); + assertTrue(result.contains("projectName is required")); //$NON-NLS-1$ + assertTrue(result.contains("\"success\":false")); //$NON-NLS-1$ + } + + @Test + public void testInvalidSeverityRejectedBeforeWorkspaceAccess() + { + Map params = new HashMap<>(); + params.put("projectName", "AnyProject"); //$NON-NLS-1$ //$NON-NLS-2$ + params.put("severity", "catastrophic"); //$NON-NLS-1$ //$NON-NLS-2$ + String result = new CodeReviewTool().execute(params); + assertTrue(result.contains("Invalid severity")); //$NON-NLS-1$ + assertTrue(result.contains("\"success\":false")); //$NON-NLS-1$ + } + + // ==================== Rendering (headless, via BslLsReport.parse) ==================== + + @Test + public void testRenderSummaryTableAndSteering() + { + String md = CodeReviewTool.render(BslLsReport.parse(SAMPLE), "MyProject", null, //$NON-NLS-1$ + new File("."), null, null, null, 100); //$NON-NLS-1$ + assertTrue(md.contains("Code review — MyProject")); //$NON-NLS-1$ + // Summary counts (full report): 1 warning + 1 information. + assertTrue(md.contains("**2** finding(s)")); //$NON-NLS-1$ + assertTrue(md.contains("1 warning")); //$NON-NLS-1$ + assertTrue(md.contains("1 information")); //$NON-NLS-1$ + // Both rules present in the table, plus the fix-and-verify steering. + assertTrue(md.contains("MagicNumber")); //$NON-NLS-1$ + assertTrue(md.contains("UnusedLocalVariable")); //$NON-NLS-1$ + assertTrue(md.contains("write_module_source")); //$NON-NLS-1$ + assertTrue(md.contains("re-run code_review")); //$NON-NLS-1$ + // Location is actionable: the module file appears in the Module path column. + assertTrue(md.contains("Module.bsl")); //$NON-NLS-1$ + } + + @Test + public void testRenderSeverityFilterDropsLowerSeverity() + { + // Minimum severity = warning: the Information-level MagicNumber row must be excluded. Assert on + // the Docs href, which appears only in a table row (the rule name itself also occurs in the + // fix-and-verify steering text, so a bare-word check would be a false positive). + String md = CodeReviewTool.render(BslLsReport.parse(SAMPLE), "MyProject", null, //$NON-NLS-1$ + new File("."), null, "warning", null, 100); //$NON-NLS-1$ //$NON-NLS-2$ + assertTrue(md.contains("diagnostics/UnusedLocalVariable")); //$NON-NLS-1$ + assertFalse("MagicNumber (Information) row must be filtered out at minSeverity=warning", //$NON-NLS-1$ + md.contains("diagnostics/MagicNumber")); //$NON-NLS-1$ + } + + @Test + public void testRenderRuleFilterKeepsOnlyMatching() + { + String md = CodeReviewTool.render(BslLsReport.parse(SAMPLE), "MyProject", null, //$NON-NLS-1$ + new File("."), null, null, "Magic", 100); //$NON-NLS-1$ //$NON-NLS-2$ + assertTrue(md.contains("diagnostics/MagicNumber")); //$NON-NLS-1$ + assertFalse("UnusedLocalVariable row must be filtered out by rule=Magic", //$NON-NLS-1$ + md.contains("diagnostics/UnusedLocalVariable")); //$NON-NLS-1$ + } + + @Test + public void testRenderCleanProject() + { + String md = CodeReviewTool.render(BslLsReport.parse(EMPTY), "MyProject", null, //$NON-NLS-1$ + new File("."), null, null, null, 100); //$NON-NLS-1$ + assertTrue(md.contains("No BSL code-quality issues found")); //$NON-NLS-1$ + assertFalse("clean project must not render a table header", md.contains("| Severity |")); //$NON-NLS-1$ //$NON-NLS-2$ + } +} diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsReportTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsReportTest.java new file mode 100644 index 000000000..c96e3464d --- /dev/null +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsReportTest.java @@ -0,0 +1,190 @@ +/** + * MCP Server for EDT - Tests + * Copyright (C) 2025 DitriX (https://github.com/DitriXNew) + * Licensed under AGPL-3.0-or-later + */ + +package com.ditrix.edt.mcp.server.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.List; + +import org.junit.Test; + +import com.ditrix.edt.mcp.server.utils.BslLsReport.Finding; +import com.ditrix.edt.mcp.server.utils.BslLsReport.Severity; + +/** + * Tests for {@link BslLsReport} — the pure JSON-report parser of the BSL Language + * Server output. The sample below is a trimmed but faithful capture of a real + * {@code --reporter json} run (see MIGRATION-PLAN §6.7): it exercises the two + * conversions the parser owns — 0-based LSP line/character → 1-based and a + * {@code file://} URI carrying {@code ../} → a normalized absolute path — plus the + * severity mapping, the {@code codeDescription.href} extraction and the LSP tags. + */ +public class BslLsReportTest +{ + /** + * Two file entries: one with a MagicNumber (Information, with href) and an + * UnusedLocalVariable (Warning, tag Unnecessary), one clean. The first path + * deliberately carries a {@code ../} segment (the engine builds it relative to its + * working directory) to pin path normalization. + */ + private static final String SAMPLE = "{" + + "\"date\":\"2026-07-10 08:06:47\"," + + "\"fileinfos\":[" + + " {" + + " \"path\":\"file:///D:/GitLab/EDT-MCP/Bsl-gar/../tests/TestConfiguration/src/CommonModules/Calc/Module.bsl\"," + + " \"mdoRef\":\"CommonModule.Calc\"," + + " \"diagnostics\":[" + + " {\"code\":\"MagicNumber\"," + + " \"codeDescription\":{\"href\":\"https://1c-syntax.github.io/bsl-language-server/diagnostics/MagicNumber\"}," + + " \"message\":\"Assign this magic number to a constant\"," + + " \"range\":{\"start\":{\"character\":20,\"line\":5},\"end\":{\"character\":21,\"line\":5}}," + + " \"relatedInformation\":null,\"severity\":\"Information\",\"source\":\"bsl-language-server\",\"tags\":[]}," + + " {\"code\":\"UnusedLocalVariable\"," + + " \"codeDescription\":{\"href\":\"https://1c-syntax.github.io/bsl-language-server/diagnostics/UnusedLocalVariable\"}," + + " \"message\":\"Remove unused variable\"," + + " \"range\":{\"start\":{\"character\":1,\"line\":5},\"end\":{\"character\":10,\"line\":5}}," + + " \"relatedInformation\":null,\"severity\":\"Warning\",\"source\":\"bsl-language-server\",\"tags\":[\"Unnecessary\"]}" + + " ]," + + " \"metrics\":{\"procedures\":1,\"functions\":1,\"lines\":8,\"ncloc\":6,\"comments\":0," + + " \"statements\":2,\"cognitiveComplexity\":0,\"cyclomaticComplexity\":2}" + + " }," + + " {" + + " \"path\":\"file:///D:/GitLab/EDT-MCP/tests/TestConfiguration/src/CommonModules/OK/Module.bsl\"," + + " \"mdoRef\":\"CommonModule.OK\"," + + " \"diagnostics\":[]," + + " \"metrics\":{\"procedures\":0,\"functions\":0,\"lines\":1,\"ncloc\":0,\"comments\":0," + + " \"statements\":0,\"cognitiveComplexity\":0,\"cyclomaticComplexity\":0}" + + " }" + + "]," + + "\"sourceDir\":\"D:\\\\GitLab\\\\EDT-MCP\\\\tests\\\\TestConfiguration\\\\src\"}"; + + @Test + public void testParsesAllFindingsAndSeverityCounts() + { + BslLsReport report = BslLsReport.parse(SAMPLE); + assertEquals(2, report.total()); + assertEquals(1, report.count(Severity.INFORMATION)); + assertEquals(1, report.count(Severity.WARNING)); + assertEquals(0, report.count(Severity.ERROR)); + assertEquals(0, report.count(Severity.HINT)); + } + + @Test + public void testLineAndColumnConvertedToOneBased() + { + BslLsReport report = BslLsReport.parse(SAMPLE); + Finding magic = findByCode(report, "MagicNumber"); + // LSP 0-based line 5 / character 20 -> 1-based 6 / 21. + assertEquals(6, magic.line()); + assertEquals(21, magic.column()); + } + + @Test + public void testSeverityHrefAndMdoRefMapped() + { + BslLsReport report = BslLsReport.parse(SAMPLE); + Finding magic = findByCode(report, "MagicNumber"); + assertEquals(Severity.INFORMATION, magic.severity()); + assertEquals("CommonModule.Calc", magic.mdoRef()); + assertNotNull(magic.href()); + assertTrue(magic.href().contains("MagicNumber")); + } + + @Test + public void testTagsParsed() + { + BslLsReport report = BslLsReport.parse(SAMPLE); + Finding unused = findByCode(report, "UnusedLocalVariable"); + assertEquals(Severity.WARNING, unused.severity()); + assertTrue(unused.tags().contains("Unnecessary")); + assertTrue(findByCode(report, "MagicNumber").tags().isEmpty()); + } + + @Test + public void testPathNormalizedRemovesDotDotSegments() + { + BslLsReport report = BslLsReport.parse(SAMPLE); + Finding magic = findByCode(report, "MagicNumber"); + assertNotNull(magic.path()); + assertFalse("normalized path must not keep ../ segments: " + magic.path(), + magic.path().contains("..")); + assertTrue("path should end at the module file: " + magic.path(), + magic.path().endsWith("Module.bsl")); + } + + @Test + public void testMetricsParsed() + { + BslLsReport report = BslLsReport.parse(SAMPLE); + assertEquals(2, report.metrics().size()); + BslLsReport.FileMetrics calc = report.metrics().get(0); + assertEquals("CommonModule.Calc", calc.mdoRef()); + assertEquals(2, calc.cyclomaticComplexity()); + assertEquals(6, calc.ncloc()); + assertEquals(1, calc.procedures()); + assertEquals(1, calc.functions()); + } + + @Test + public void testEmptyReportIsEmptyNotError() + { + BslLsReport report = BslLsReport.parse("{\"fileinfos\":[]}"); + assertEquals(0, report.total()); + assertTrue(report.findings().isEmpty()); + assertTrue(report.metrics().isEmpty()); + } + + @Test + public void testMissingFileinfosKeyTolerated() + { + BslLsReport report = BslLsReport.parse("{}"); + assertEquals(0, report.total()); + } + + @Test + public void testNonObjectJsonThrows() + { + try + { + BslLsReport.parse("[]"); + fail("expected IllegalArgumentException for a non-object report"); + } + catch (IllegalArgumentException expected) + { + // ok + } + } + + @Test + public void testSeverityFromTokenFallsBackToInformation() + { + assertEquals(Severity.ERROR, Severity.fromToken("Error")); + assertEquals(Severity.WARNING, Severity.fromToken("Warning")); + assertEquals(Severity.HINT, Severity.fromToken("Hint")); + assertEquals(Severity.INFORMATION, Severity.fromToken("Information")); + assertEquals(Severity.INFORMATION, Severity.fromToken("Whatever")); + assertEquals(Severity.INFORMATION, Severity.fromToken(null)); + } + + private static Finding findByCode(BslLsReport report, String code) + { + List all = report.findings(); + for (Finding f : all) + { + if (code.equals(f.code())) + { + return f; + } + } + fail("no finding with code " + code); + return null; + } +} diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java new file mode 100644 index 000000000..00e9bff26 --- /dev/null +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java @@ -0,0 +1,167 @@ +/** + * MCP Server for EDT - Tests + * Copyright (C) 2025 DitriX (https://github.com/DitriXNew) + * Licensed under AGPL-3.0-or-later + */ + +package com.ditrix.edt.mcp.server.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for {@link BslLsRunner}'s resolution logic — the parts that decide WHICH jar, + * Java and configuration file are used, without spawning the engine. The subprocess + * path itself is validated live/e2e (it needs the real jar + a Java 21). These tests + * cover the deterministic, side-effect-free resolution rules on temp files; they + * avoid asserting the env-var branch because the ambient environment differs between + * the developer machine (where {@code EDT_MCP_BSL_LS_*} are set) and CI. + *

+ * Uses {@code java.nio.file} temp dirs directly rather than JUnit's + * {@code TemporaryFolder}, which the Tycho target platform treats as non-API. + */ +public class BslLsRunnerTest +{ + private Path root; + + @Before + public void setUp() throws IOException + { + root = Files.createTempDirectory("bslls-test"); + } + + @After + public void tearDown() throws IOException + { + if (root != null && Files.exists(root)) + { + Files.walk(root) + .sorted(Comparator.reverseOrder()) + .forEach(p -> { + try + { + Files.deleteIfExists(p); + } + catch (IOException ignored) + { + // best effort + } + }); + } + } + + @Test + public void testJarOverrideWinsWhenItIsAFile() throws IOException + { + File jar = newFile("bsl-language-server-1.0.3-exec.jar"); + assertEquals(jar, BslLsRunner.resolveJar(jar)); + } + + @Test + public void testBogusJarOverrideIsNeverReturned() + { + File bogus = new File(root.toFile(), "does-not-exist-exec.jar"); + File resolved = BslLsRunner.resolveJar(bogus); + // Falls through to env/default: whatever comes back, it is never the bogus path + // and, if anything, it is a real existing file. + assertFalse(bogus.equals(resolved)); + if (resolved != null) + { + assertTrue(resolved.isFile()); + } + } + + @Test + public void testJavaOverrideWins() throws IOException + { + File java = newFile("java.exe"); + assertEquals(java, BslLsRunner.resolveJava(java)); + } + + @Test + public void testJavaAlwaysResolvableViaJavaHomeFallback() + { + // No override: env or the java.home of the running JVM must yield a launcher. + assertNotNull(BslLsRunner.resolveJava(null)); + } + + @Test + public void testConfigPrefersProjectConfig() throws IOException + { + File jarDir = newFolder("engine"); + File jar = new File(jarDir, "bsl-language-server-1.0.3-exec.jar"); + assertTrue(jar.createNewFile()); + File engineHomeConfig = new File(jarDir, ".bsl-language-server.json"); + assertTrue(engineHomeConfig.createNewFile()); + + File projectConfig = newFile(".bsl-language-server.json"); + assertEquals(projectConfig, BslLsRunner.resolveConfig(projectConfig, jar)); + } + + @Test + public void testConfigFallsBackToEngineHome() throws IOException + { + File jarDir = newFolder("engine"); + File jar = new File(jarDir, "bsl-language-server-1.0.3-exec.jar"); + assertTrue(jar.createNewFile()); + File engineHomeConfig = new File(jarDir, ".bsl-language-server.json"); + assertTrue(engineHomeConfig.createNewFile()); + + assertEquals(engineHomeConfig, BslLsRunner.resolveConfig(null, jar)); + } + + @Test + public void testConfigNullWhenNeitherPresent() throws IOException + { + File jarDir = newFolder("engine"); + File jar = new File(jarDir, "bsl-language-server-1.0.3-exec.jar"); + assertTrue(jar.createNewFile()); + // No sibling config, no project config. + assertNull(BslLsRunner.resolveConfig(null, jar)); + } + + @Test + public void testRunRejectsMissingSourceDirectory() + { + File missing = new File(root.toFile(), "no-such-src"); + BslLsRunner.Result result = BslLsRunner.run(new BslLsRunner.Request(missing)); + assertFalse(result.ok()); + assertNotNull(result.errorMessage()); + assertTrue(result.errorMessage().contains("Source directory")); + } + + @Test + public void testRunRejectsNullRequest() + { + BslLsRunner.Result result = BslLsRunner.run(null); + assertFalse(result.ok()); + assertNotNull(result.errorMessage()); + } + + private File newFile(String name) throws IOException + { + File f = new File(root.toFile(), name); + assertTrue(f.createNewFile()); + return f; + } + + private File newFolder(String name) + { + File f = new File(root.toFile(), name); + assertTrue(f.mkdirs()); + return f; + } +} diff --git a/tests/e2e/tools/test_code_review.py b/tests/e2e/tools/test_code_review.py new file mode 100644 index 000000000..8fc5fe4a8 --- /dev/null +++ b/tests/e2e/tools/test_code_review.py @@ -0,0 +1,162 @@ +""" +e2e tests for code_review (kind: read). + +code_review runs the external BSL Language Server engine over a project (or one +module) and renders its diagnostics as a Markdown table. It is the delta over +get_project_errors: code METRICS (magic number, complexity, ...) that EDT's own +checks do not raise. Response is the Markdown string -> Result.text; the error path +goes through ToolResult.error(...).toJson() -> Result.structured.error. + +The engine (jar + Java) is an external, env-provided dependency +(EDT_MCP_BSL_LS_JAR / EDT_MCP_BSL_LS_JAVA). Where it is configured (the dev machine), +the happy paths assert REAL findings on TestConfiguration — its CommonModules/Calc and +Configuration/ManagedApplicationModule carry MagicNumber, and CommonModules/Error +carries a ParseError. Where it is NOT configured (e.g. CI), the tool returns its +ACTIONABLE engine-not-found error and the happy paths SKIP — but ONLY for that specific +error; any other error still fails, and the content assertions still catch a no-op tool. + +The negative matrix runs regardless of the engine: every rejection there happens BEFORE +the engine is launched (missing/invalid args, unknown project/module). + +Read tool => every test asserts assert_no_diff(): analysis writes only to a system temp +dir (cleaned up) and must never mutate the project on disk. +""" + +from harness import ( + call, assert_ok, assert_contains, assert_not_contains, assert_error, + assert_error_quality, assert_no_diff, e2e_test, PROJECT, E2ESkip, _fail, +) + +# Substrings that identify the actionable "engine not installed" error +# (see BslLsRunner.jarNotFoundMessage): both must be present to treat it as a skip. +ENGINE_MISSING_MARKERS = ("EDT_MCP_BSL_LS_JAR", "bsl-language-server") + +# A module of TestConfiguration known to carry a metric finding (MagicNumber). +CALC_MODULE = "CommonModules/Calc/Module.bsl" + + +def _run_or_skip(args, ctx): + """Call code_review; return the Result when the engine actually ran. If the engine + jar/Java is not configured, the tool returns its actionable not-found error -> SKIP + (an unmet precondition, not a failure). Any OTHER error is a real failure.""" + r = call("code_review", args) + if r.is_error: + err = r.error_text() or "" + if all(m in err for m in ENGINE_MISSING_MARKERS): + raise E2ESkip("BSL Language Server engine not configured: " + ctx) + _fail(ctx + " -> unexpected error: " + err) + return r + + +# ────────────────────────────────────────────────────────────────────────────── +# HAPPY PATHS (engine-gated: real findings where configured, else skip) +# ────────────────────────────────────────────────────────────────────────────── + +@e2e_test(tool="code_review", kind="read") +def test_reports_metric_findings_for_project(): + """A whole-project review runs the engine and reports its METRIC findings — the + delta over get_project_errors. MagicNumber is present in TestConfiguration, so a + working tool renders it; a no-op/broken tool fails the content assertions.""" + r = _run_or_skip({"projectName": PROJECT}, "whole-project review") + assert_ok(r, "code_review whole-project happy path") + assert_contains(r.text, "Code review — " + PROJECT, "must render the scope heading naming the project") + assert_contains(r.text, "finding(s)", "must render the findings summary line") + # The metric delta the whole tool exists for. + assert_contains(r.text, "MagicNumber", "the engine's MagicNumber metric must be reported") + # The auto-remediation steering (fix at Module path + Line, then re-verify). + assert_contains(r.text, "write_module_source", "output must steer to fixing via write_module_source") + assert_no_diff("reviewing code must not touch the project on disk") + + +@e2e_test(tool="code_review", kind="read") +def test_module_scope_echoes_path_and_finds_metric(): + """Scoping to one module echoes that module in the heading AND still surfaces its + metric finding (Calc has a MagicNumber). Proves modulePath narrows the review rather + than being ignored.""" + r = _run_or_skip({"projectName": PROJECT, "modulePath": CALC_MODULE}, "single-module review") + assert_ok(r, "code_review single-module happy path") + assert_contains(r.text, CALC_MODULE, "the heading must echo the requested module path") + assert_contains(r.text, "MagicNumber", "the module's MagicNumber finding must be reported") + assert_no_diff("reviewing one module must not touch the project on disk") + + +@e2e_test(tool="code_review", kind="read") +def test_rule_filter_narrows_to_matching_rule(): + """rule='Magic' keeps only magic-number diagnostics: the MagicNumber doc link is + present and an unrelated rule's doc link (UnusedLocalVariable, which Calc also has) + is filtered out. Proves the rule filter is applied, not dropped.""" + r = _run_or_skip({"projectName": PROJECT, "rule": "Magic"}, "rule-filtered review") + assert_ok(r, "code_review rule filter happy path") + assert_contains(r.text, "diagnostics/Magic", "a MagicNumber row must survive rule='Magic'") + assert_not_contains(r.text, "diagnostics/UnusedLocalVariable", + "a non-matching rule must be filtered out by rule='Magic'") + assert_no_diff("a filtered review must not touch the project on disk") + + +@e2e_test(tool="code_review", kind="read") +def test_severity_filter_drops_lower_severities(): + """severity='error' (minimum) keeps Error-level diagnostics (ParseError exists in + TestConfiguration) and drops the Information-level MagicNumber. Proves the + minimum-severity filter, not just that some rows appear.""" + r = _run_or_skip({"projectName": PROJECT, "severity": "error"}, "severity-filtered review") + assert_ok(r, "code_review severity filter happy path") + assert_contains(r.text, "ParseError", "an Error-level diagnostic must remain at severity='error'") + assert_not_contains(r.text, "diagnostics/MagicNumber", + "an Information-level finding must be dropped at severity='error'") + assert_no_diff("a filtered review must not touch the project on disk") + + +# ────────────────────────────────────────────────────────────────────────────── +# NEGATIVE MATRIX (deterministic: rejected before the engine is launched) +# ────────────────────────────────────────────────────────────────────────────── + +@e2e_test(tool="code_review", kind="read") +def test_missing_project_name_is_rejected(): + """projectName is required; omitting it errors before any engine/workspace access.""" + r = call("code_review", {}) + err = assert_error(r, "missing projectName") + assert_contains(err, "projectName", "the required-arg error must name projectName") + assert_no_diff("a rejected call must not touch the project on disk") + + +@e2e_test(tool="code_review", kind="read") +def test_nonexistent_project_is_rejected(): + """A non-existent projectName must error (names the value, points at list_projects), + reached before the engine runs.""" + bad = "NoSuchProject_e2e_xyz" + r = call("code_review", {"projectName": bad}) + err = assert_error(r, "non-existent projectName") + assert_error_quality( + err, + names=[bad], + suggests=["list_projects"], + ctx="non-existent project: names the bad value and points at list_projects", + ) + assert_no_diff("a rejected call must not touch the project on disk") + + +@e2e_test(tool="code_review", kind="read") +def test_invalid_severity_is_rejected_with_valid_set(): + """An out-of-set severity must be REJECTED with the accepted values listed, before the + engine runs (so it is deterministic even without the jar).""" + r = call("code_review", {"projectName": PROJECT, "severity": "catastrophic"}) + err = assert_error(r, "invalid severity enum") + assert_error_quality( + err, + names=["catastrophic"], + suggests=["severity", "error", "warning"], + ctx="invalid severity echoes the bad value and lists the valid set", + ) + assert_no_diff("a rejected call must not touch the project on disk") + + +@e2e_test(tool="code_review", kind="read") +def test_nonexistent_module_is_rejected(): + """A modulePath that resolves to no module must error (naming the src/ path), reached + after project resolution but before the engine — so it is deterministic.""" + bad_module = "CommonModules/NoSuchModule_e2e/Module.bsl" + r = call("code_review", {"projectName": PROJECT, "modulePath": bad_module}) + err = assert_error(r, "non-existent modulePath") + assert_contains(err, "Module not found", "the error must state the module was not found") + assert_contains(err, bad_module, "the error must name the bad module path") + assert_no_diff("a rejected call must not touch the project on disk") From e5e8d4e49f1b8d109e845e2632f572befa7753c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B0=D1=80=D0=B0=D1=81=D0=BE=D0=B2=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Mon, 3 Aug 2026 08:42:09 +0300 Subject: [PATCH 2/8] =?UTF-8?q?code=5Freview:=20=D1=81=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=B0/=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=84=D0=B0=D0=BA=D1=82=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D1=83=20scope=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8F,=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=82=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=D0=B0=20=D0=B7=D0=B0=20?= =?UTF-8?q?src/,=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20=D0=BF=D0=BE=20dia?= =?UTF-8?q?gnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три замечания бота плюс одно от DitriX, все по CodeReviewTool: 1. При modulePath движок всё равно анализирует всю содержащую директорию (это его единица анализа), и раньше сводные счётчики ("N findings: X error, Y warning...") считались по ВСЕМУ отчёту, включая соседние модули из той же папки - обзор одного файла мог показать находки чужого соседа или скрыть, что сам файл чист. Теперь "scoped" (отфильтрованный по целевому модулю набор) считается ПЕРВЫМ, и именно по нему строится сводка; severity/rule/excludeRule остаются чисто отображающими фильтрами поверх него, не влияя на итоговые счётчики. 2. modulePath резолвился через BslModuleUtils.resolveModuleFile, который принимает абсолютные пути и пути с '..', разрешая их против ВСЕЙ Eclipse workspace, а не только src/ запрошенного проекта. Без проверки вызывающий мог указать modulePath на соседний проект (или вообще любое место в workspace) - и он тихо анализировался бы под шапкой другого проекта. Добавлена isWithinSrc: путь обязан остаться внутри срезолвленного srcRoot (сравнение по сегментам пути, не по префиксу строки - иначе "src-evil" ложно прошёл бы проверку для "src"). 3. DitriX справедливо указал: инструмент называет себя "дельтой поверх get_project_errors" (только метрики, которых нет в EDT), но по факту отдаёт ВЕСЬ каталог diagnostics движка - включая правила, дублирующие v8-code-style. Вместо угадывания whitelist "настоящих метрик" (риск ошибиться в неполном/неточном списке правил движка) контракт честно переопределён: это полный каталог движка, пересечение с get_project_errors задокументировано, и добавлен excludeRule - фильтр по подстроке id правила, исключающий уже дублирующиеся находки (зеркало существующего rule-фильтра). Проверено: mvn clean verify -> BUILD SUCCESS, 4105 тестов. --- .../guides/code_review.md | 10 +- .../mcp/server/tools/impl/CodeReviewTool.java | 151 ++++++++++++++--- .../server/tools/impl/CodeReviewToolTest.java | 159 +++++++++++++++++- 3 files changed, 289 insertions(+), 31 deletions(-) diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md b/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md index 7ca1303a0..77861518a 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md +++ b/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md @@ -1,9 +1,9 @@ Review BSL code quality by running the external BSL Language Server engine over a project (or a single module) and reporting its diagnostics as an actionable table. Every finding is a concrete defect located by `Module path` + `Line` — the same coordinates `read_module_source` and `write_module_source` use — so the intended workflow is **review → fix → re-run to verify**. ## When to use -- To surface code-metric defects EDT's own checks do not raise: magic numbers/dates, cyclomatic & cognitive complexity, method/line length, parameter counts, nesting, deprecated calls, service tags, and more. +- To run the BSL Language Server's FULL diagnostic catalog over your code: magic numbers/dates, cyclomatic & cognitive complexity, method/line length, parameter counts, nesting, deprecated calls, unused code, naming, service tags, and well over a hundred more rules. - As the first step of an automated clean-up loop: run `code_review`, fix each finding in place with `write_module_source`, then run `code_review` again (optionally scoped to the one module) to confirm the finding is gone. -- Prefer `get_project_errors` when you want EDT's configuration-development standards (`v8-code-style`) — that half is already covered there. `code_review` is the BSL Language Server metric layer on top. +- **This is NOT a strict delta over `get_project_errors`.** Both `code_review` and EDT's own `v8-code-style` (surfaced by `get_project_errors`) are BSL static analyzers with a PARTIALLY SHARED rule set, so some findings here will duplicate ones you already saw there. Use `get_project_errors` for EDT's native check surface, `code_review` for the (larger, partially different) BSL Language Server rule set, or run both to cross-check. Pass `excludeRule` to drop rule ids you already get elsewhere so they stop double-reporting. ## How the findings should be handled The rows are defects to FIX, not just a report: @@ -16,7 +16,8 @@ The rows are defects to FIX, not just a report: - `modulePath` — narrow the review to a single module, given as a path from `src/` (e.g. `CommonModules/Calc/Module.bsl`). Omit to review the whole configuration. This is the same path form the `Module path` column returns, so you can feed a row straight back in. - `severity` — minimum severity to report: `error` > `warning` > `information` > `hint`. Omit to report every severity. (These are the engine's LSP severities, independent of EDT's BLOCKER/MAJOR/… taxonomy.) - `rule` — report only diagnostics whose rule id contains this substring, case-insensitive (e.g. `Magic`, `Complexity`, `Unused`). Handy for a focused pass or a targeted re-verify. -- `limit` — maximum number of rows to render; default 100, capped at 1000. The summary counts above the table always reflect the full report, not the capped table. +- `excludeRule` — drop diagnostics whose rule id contains this substring, case-insensitive — e.g. to exclude rules you already get from `get_project_errors` and avoid reviewing the same issue twice. +- `limit` — maximum number of rows to render; default 100, capped at 1000. The summary counts above the table reflect the requested SCOPE (the whole project, or just the target module when `modulePath` narrows it) — `severity`/`rule`/`excludeRule` narrow only which rows are DISPLAYED in the table below, not the summary counts. ## Output - Markdown. A heading with the scope, a one-line summary of counts per severity, a short instruction to fix-and-re-verify, then a table with columns: `Severity`, `Rule`, `Module path`, `Line`, `Message`, `Docs` (the rule's documentation URL). @@ -38,9 +39,12 @@ The rows are defects to FIX, not just a report: - One module, fast re-verify after a fix: `{projectName: "MyProject", modulePath: "CommonModules/Calc/Module.bsl"}`. - Only the important ones: `{projectName: "MyProject", severity: "warning"}`. - Only magic numbers: `{projectName: "MyProject", rule: "Magic"}`. +- Skip a rule already covered elsewhere: `{projectName: "MyProject", excludeRule: "SemicolonPresence"}`. ## Notes & gotchas - Line numbers are 1-based (converted from the engine's 0-based LSP output), matching `read_module_source`/`set_breakpoint`. - `Module path` is relativized to `src/`; a finding outside `src/` (rare) shows its absolute path instead. - The engine analyzes files on disk. If you just edited a module through the model, ensure it is exported to disk (the write tools do this) before reviewing, or the review may read a stale file. - A large configuration can take a while to analyze; scope with `modulePath` for quick iterative checks. +- The engine's report is capped at 50 MB; a report larger than that (a pathological run, or a misconfigured/corrupt engine process) is rejected with an actionable error instead of being read into memory — narrow the scope with `modulePath` and re-run. +- A `modulePath` must resolve INSIDE the requested project's own `src/` — an absolute path or one using `..` to point elsewhere is rejected. diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java index bd28c1a6c..270161099 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java @@ -38,10 +38,18 @@ /** * Reviews BSL code quality by running the external BSL Language Server engine over a - * project (or one module) and rendering its diagnostics as an actionable table. This - * is the delta over {@code get_project_errors}: EDT's own {@code v8-code-style} - * checks already surface there, but the engine's metrics (magic number, - * cyclomatic/cognitive complexity, method/line length, nesting, …) are not in EDT. + * project (or one module) and rendering its diagnostics as an actionable table. + *

+ * This is the engine's FULL diagnostic catalog, not a curated "metrics-only" + * subset — every rule the engine ships (magic number, cyclomatic/cognitive + * complexity, method/line length, nesting, naming, unused code, … together, well over + * a hundred rules) comes through. Some of these OVERLAP with EDT's own + * {@code v8-code-style} checks surfaced by {@code get_project_errors} (both are BSL + * static analyzers with a partially shared rule set) — this is not a strict delta over + * it. Use {@code get_project_errors} for EDT's native check surface; use this tool for + * the (larger, partially different) BSL Language Server rule set, or to cross-check the + * two. Pass {@code excludeRule} to drop rule ids you already get elsewhere (e.g. ones + * {@code get_project_errors} already reports) so they do not double up in your review. *

* The engine runs as a subprocess (see {@link BslLsRunner}); we do not implement any * rules ourselves. Each row is a concrete defect located by {@code Module path} + @@ -67,12 +75,14 @@ public String getName() @Override public String getDescription() { - return "Review BSL code quality with the BSL Language Server engine: reports code-metric defects " //$NON-NLS-1$ - + "(magic number, cyclomatic/cognitive complexity, method/line length, nesting, …) that EDT's own " //$NON-NLS-1$ - + "checks do not cover. Each finding is a defect to FIX: it carries the rule, severity, Module path and " //$NON-NLS-1$ - + "Line, ready for read_module_source / write_module_source — fix each, then re-run code_review to verify. " //$NON-NLS-1$ - + "Scope the whole project or one module; filter by severity or rule. Needs the engine jar (see the guide). " //$NON-NLS-1$ - + "Full parameters and examples: call get_tool_guide('code_review')."; //$NON-NLS-1$ + return "Review BSL code quality with the BSL Language Server engine: its FULL diagnostic catalog " //$NON-NLS-1$ + + "(magic number, cyclomatic/cognitive complexity, method/line length, nesting, naming, unused " //$NON-NLS-1$ + + "code, …) — this overlaps with EDT's own v8-code-style checks (get_project_errors), it is not a " //$NON-NLS-1$ + + "strict delta over them; use excludeRule to drop rule ids you already get elsewhere. Each " //$NON-NLS-1$ + + "finding is a defect to FIX: it carries the rule, severity, Module path and Line, ready for " //$NON-NLS-1$ + + "read_module_source / write_module_source — fix each, then re-run code_review to verify. " //$NON-NLS-1$ + + "Scope the whole project or one module; filter by severity, rule or excludeRule. Needs the " //$NON-NLS-1$ + + "engine jar (see the guide). Full parameters and examples: call get_tool_guide('code_review')."; //$NON-NLS-1$ } @Override @@ -88,6 +98,9 @@ public String getInputSchema() "error", "warning", "information", "hint") //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ .stringProperty("rule", //$NON-NLS-1$ "Optional: report only diagnostics whose rule id contains this substring (e.g. 'Magic', 'Complexity').") //$NON-NLS-1$ + .stringProperty("excludeRule", //$NON-NLS-1$ + "Optional: drop diagnostics whose rule id contains this substring — e.g. to exclude rules " //$NON-NLS-1$ + + "you already get from get_project_errors and avoid double-reporting the same issue.") //$NON-NLS-1$ .integerProperty(McpKeys.LIMIT, "Max findings; default 100, max 1000 (optional).") //$NON-NLS-1$ .build(); } @@ -113,6 +126,7 @@ public String execute(Map params) String modulePath = JsonUtils.extractStringArgument(params, McpKeys.MODULE_PATH); String severity = JsonUtils.extractStringArgument(params, "severity"); //$NON-NLS-1$ String rule = JsonUtils.extractStringArgument(params, "rule"); //$NON-NLS-1$ + String excludeRule = JsonUtils.extractStringArgument(params, "excludeRule"); //$NON-NLS-1$ int limit = Pagination.clampLimit(JsonUtils.extractIntArgument(params, McpKeys.LIMIT, DEFAULT_LIMIT), MAX_LIMIT); if (severity != null && !severity.isEmpty() @@ -153,18 +167,36 @@ public String execute(Map params) return ToolResult.error("Module not found: src/" + modulePath //$NON-NLS-1$ + ". Pass a path from src/, e.g. 'CommonModules/Calc/Module.bsl'.").toJson(); //$NON-NLS-1$ } + // resolveModuleFile also accepts absolute paths and workspace-relative paths carrying + // '..' segments, resolving against the WHOLE Eclipse workspace rather than just this + // project's src/. Without this check a caller could point modulePath at a sibling + // project (or any workspace-visible location) and have it silently analyzed instead of + // rejected as out of scope for the requested project. + if (!isWithinSrc(srcRoot, moduleOsFile)) + { + return ToolResult.error("modulePath '" + modulePath + "' resolves outside project '" //$NON-NLS-1$ //$NON-NLS-2$ + + projectName + "'s own src/ folder (" + srcRoot.getAbsolutePath() + "). Pass a path " //$NON-NLS-1$ //$NON-NLS-2$ + + "relative to src/ that stays inside this project, e.g. 'CommonModules/Calc/Module.bsl' " //$NON-NLS-1$ + + "— not an absolute path or one using '..' to escape src/.").toJson(); //$NON-NLS-1$ + } targetAbsPath = normalize(moduleOsFile.getAbsolutePath()); scopeDir = moduleOsFile.getParentFile(); } - BslLsRunner.Request request = new BslLsRunner.Request(scopeDir).configFile(projectConfig(srcRoot, project)); + BslLsRunner.Request request = new BslLsRunner.Request(scopeDir) + .configFile(projectConfig(srcRoot, project)) + // Pin the workspace root to the project's own src/ regardless of how narrow scopeDir is + // for a single-module review, so report paths and workspace-local settings stay scoped + // to THIS project (see BslLsRunner#buildCommand). + .workspaceDir(srcRoot); BslLsRunner.Result result = BslLsRunner.run(request); if (!result.ok()) { return ToolResult.error(result.errorMessage()).toJson(); } - return render(result.report(), projectName, modulePath, srcRoot, targetAbsPath, severity, rule, limit); + return render(result.report(), projectName, modulePath, srcRoot, targetAbsPath, severity, rule, + excludeRule, limit); } /** @@ -205,27 +237,45 @@ private static File projectConfig(File srcRoot, IProject project) * @param projectName the reviewed project * @param modulePath the requested single-module scope, or {@code null} for whole-project * @param srcRoot the project's {@code src} directory (to relativize paths to {@code Module path}) - * @param targetAbsPath when scoped to one module, its normalized absolute path (findings are - * filtered to it); {@code null} for whole-project + * @param targetAbsPath when scoped to one module, its normalized absolute path (both the + * summary counts and the displayed rows are narrowed to it, since the engine + * analyzes the whole containing directory but the caller asked about one file); + * {@code null} for whole-project * @param severityMin the minimum-severity filter name, or {@code null} for all - * @param rule the rule-substring filter, or {@code null} for all + * @param rule the rule-substring INCLUDE filter, or {@code null} for all + * @param excludeRule the rule-substring EXCLUDE filter (e.g. to drop rules already covered by + * get_project_errors), or {@code null} to exclude none * @param limit the maximum number of rows to render * @return the Markdown result */ static String render(BslLsReport report, String projectName, String modulePath, File srcRoot, - String targetAbsPath, String severityMin, String rule, int limit) + String targetAbsPath, String severityMin, String rule, String excludeRule, int limit) { int minRank = severityMin == null || severityMin.isEmpty() ? Integer.MIN_VALUE : rank(Severity.valueOf(severityMin.toUpperCase(Locale.ROOT))); String ruleNeedle = rule == null ? null : rule.toLowerCase(Locale.ROOT); - - List filtered = new ArrayList<>(); + String excludeNeedle = excludeRule == null || excludeRule.isEmpty() ? null : excludeRule.toLowerCase(Locale.ROOT); + + // Module scope FIRST: when modulePath narrows to one file, the engine still analyzed the + // whole containing directory (its unit of analysis), so report.findings() carries every + // sibling's diagnostics too. "scoped" is what the requested review is actually ABOUT — the + // summary counts below are computed from it (not the raw, unfiltered report) so a + // single-module review's totals never include issues from files the caller never asked + // about. severity/rule stay pure DISPLAY filters on top of that (independent of the totals, + // same as the `limit` cap — see the class guide). + List scoped = new ArrayList<>(); for (Finding f : report.findings()) { if (targetAbsPath != null && !targetAbsPath.equals(normalize(f.path()))) { continue; } + scoped.add(f); + } + + List filtered = new ArrayList<>(); + for (Finding f : scoped) + { if (rank(f.severity()) < minRank) { continue; @@ -234,6 +284,10 @@ static String render(BslLsReport report, String projectName, String modulePath, { continue; } + if (excludeNeedle != null && f.code() != null && f.code().toLowerCase(Locale.ROOT).contains(excludeNeedle)) + { + continue; + } filtered.add(f); } filtered.sort(Comparator.comparingInt((Finding f) -> rank(f.severity())).reversed() @@ -244,13 +298,13 @@ static String render(BslLsReport report, String projectName, String modulePath, String scope = modulePath == null || modulePath.isEmpty() ? projectName : projectName + " / " + modulePath; //$NON-NLS-1$ md.append("# Code review — ").append(MarkdownUtils.escapeForTable(scope)).append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$ - md.append("**").append(report.total()).append("** finding(s): ") //$NON-NLS-1$ //$NON-NLS-2$ - .append(report.count(Severity.ERROR)).append(" error, ") //$NON-NLS-1$ - .append(report.count(Severity.WARNING)).append(" warning, ") //$NON-NLS-1$ - .append(report.count(Severity.INFORMATION)).append(" information, ") //$NON-NLS-1$ - .append(report.count(Severity.HINT)).append(" hint.\n\n"); //$NON-NLS-1$ + md.append("**").append(scoped.size()).append("** finding(s): ") //$NON-NLS-1$ //$NON-NLS-2$ + .append(countBySeverity(scoped, Severity.ERROR)).append(" error, ") //$NON-NLS-1$ + .append(countBySeverity(scoped, Severity.WARNING)).append(" warning, ") //$NON-NLS-1$ + .append(countBySeverity(scoped, Severity.INFORMATION)).append(" information, ") //$NON-NLS-1$ + .append(countBySeverity(scoped, Severity.HINT)).append(" hint.\n\n"); //$NON-NLS-1$ - if (report.total() == 0) + if (scoped.isEmpty()) { md.append("No BSL code-quality issues found. "); //$NON-NLS-1$ md.append("(If you expected findings, confirm the engine jar and configuration — see get_tool_guide('code_review').)\n"); //$NON-NLS-1$ @@ -289,6 +343,55 @@ static String render(BslLsReport report, String projectName, String modulePath, return md.toString(); } + /** + * @param findings the findings to count over (already scoped to what the summary should + * reflect — see the module-scope note in {@link #render}) + * @param severity the severity to count + * @return how many entries of {@code findings} carry that severity + */ + private static int countBySeverity(List findings, Severity severity) + { + int n = 0; + for (Finding f : findings) + { + if (f.severity() == severity) + { + n++; + } + } + return n; + } + + /** + * Guards {@code modulePath} resolution: {@link BslModuleUtils#resolveModuleFile} accepts + * both a {@code src/}-relative path AND an absolute path (resolved against the WHOLE + * Eclipse workspace, not just this project), and a relative path can carry {@code ..} + * segments. Without this check a caller could point {@code modulePath} outside the + * requested project's own {@code src/} (a sibling project, or any workspace-visible + * location) and have it silently analyzed instead of rejected as out of scope. + * + * @param srcRoot the requested project's own {@code src} directory + * @param candidate the resolved module file's on-disk location + * @return {@code true} when {@code candidate} is {@code srcRoot} itself or a descendant of it + */ + static boolean isWithinSrc(File srcRoot, File candidate) + { + if (srcRoot == null || candidate == null) + { + return false; + } + try + { + Path root = srcRoot.toPath().toAbsolutePath().normalize(); + Path c = candidate.toPath().toAbsolutePath().normalize(); + return c.startsWith(root); + } + catch (RuntimeException e) + { + return false; + } + } + /** Severity importance rank; higher is more severe (Error highest, Hint lowest). */ private static int rank(Severity s) { diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java index 2b487dc80..b9cf4811e 100644 --- a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java @@ -54,6 +54,33 @@ public class CodeReviewToolTest private static final String EMPTY = "{\"fileinfos\":[]}"; + /** + * Two SIBLING modules under the same directory (the engine's unit of analysis is the + * whole containing directory, not one file): {@code Module.bsl} carries one Information + * finding, {@code Helper.bsl} carries one Warning finding. Models a {@code modulePath} + * review scoped to {@code Module.bsl} alone, where the engine's report still includes + * {@code Helper.bsl}'s finding as a directory sibling. + */ + private static final String SAMPLE_TWO_SIBLING_MODULES = "{" + + "\"fileinfos\":[" + + " {\"path\":\"file:///C:/proj/src/CommonModules/Calc/Module.bsl\",\"mdoRef\":\"CommonModule.Calc\"," + + " \"diagnostics\":[" + + " {\"code\":\"MagicNumber\"," + + " \"codeDescription\":{\"href\":\"https://1c-syntax.github.io/bsl-language-server/diagnostics/MagicNumber\"}," + + " \"message\":\"Assign this magic number to a constant\"," + + " \"range\":{\"start\":{\"character\":20,\"line\":5},\"end\":{\"character\":21,\"line\":5}}," + + " \"severity\":\"Information\",\"tags\":[]}" + + " ],\"metrics\":{\"cyclomaticComplexity\":2}}," + + " {\"path\":\"file:///C:/proj/src/CommonModules/Calc/Helper.bsl\",\"mdoRef\":\"CommonModule.CalcHelper\"," + + " \"diagnostics\":[" + + " {\"code\":\"UnusedLocalVariable\"," + + " \"codeDescription\":{\"href\":\"https://1c-syntax.github.io/bsl-language-server/diagnostics/UnusedLocalVariable\"}," + + " \"message\":\"Remove unused variable\"," + + " \"range\":{\"start\":{\"character\":1,\"line\":5},\"end\":{\"character\":10,\"line\":5}}," + + " \"severity\":\"Warning\",\"tags\":[\"Unnecessary\"]}" + + " ],\"metrics\":{\"cyclomaticComplexity\":1}}" + + "],\"sourceDir\":\"C:/proj/src\"}"; + @Test public void testName() { @@ -147,7 +174,7 @@ public void testInvalidSeverityRejectedBeforeWorkspaceAccess() public void testRenderSummaryTableAndSteering() { String md = CodeReviewTool.render(BslLsReport.parse(SAMPLE), "MyProject", null, //$NON-NLS-1$ - new File("."), null, null, null, 100); //$NON-NLS-1$ + new File("."), null, null, null, null, 100); //$NON-NLS-1$ assertTrue(md.contains("Code review — MyProject")); //$NON-NLS-1$ // Summary counts (full report): 1 warning + 1 information. assertTrue(md.contains("**2** finding(s)")); //$NON-NLS-1$ @@ -169,7 +196,7 @@ public void testRenderSeverityFilterDropsLowerSeverity() // the Docs href, which appears only in a table row (the rule name itself also occurs in the // fix-and-verify steering text, so a bare-word check would be a false positive). String md = CodeReviewTool.render(BslLsReport.parse(SAMPLE), "MyProject", null, //$NON-NLS-1$ - new File("."), null, "warning", null, 100); //$NON-NLS-1$ //$NON-NLS-2$ + new File("."), null, "warning", null, null, 100); //$NON-NLS-1$ //$NON-NLS-2$ assertTrue(md.contains("diagnostics/UnusedLocalVariable")); //$NON-NLS-1$ assertFalse("MagicNumber (Information) row must be filtered out at minSeverity=warning", //$NON-NLS-1$ md.contains("diagnostics/MagicNumber")); //$NON-NLS-1$ @@ -179,18 +206,142 @@ public void testRenderSeverityFilterDropsLowerSeverity() public void testRenderRuleFilterKeepsOnlyMatching() { String md = CodeReviewTool.render(BslLsReport.parse(SAMPLE), "MyProject", null, //$NON-NLS-1$ - new File("."), null, null, "Magic", 100); //$NON-NLS-1$ //$NON-NLS-2$ + new File("."), null, null, "Magic", null, 100); //$NON-NLS-1$ //$NON-NLS-2$ assertTrue(md.contains("diagnostics/MagicNumber")); //$NON-NLS-1$ assertFalse("UnusedLocalVariable row must be filtered out by rule=Magic", //$NON-NLS-1$ md.contains("diagnostics/UnusedLocalVariable")); //$NON-NLS-1$ } + @Test + public void testRenderExcludeRuleFilterDropsMatching() + { + // The mirror of rule=Magic above: excludeRule drops the matching rule and KEEPS the rest - + // this is the mechanism for dodging duplicate reporting against get_project_errors. + String md = CodeReviewTool.render(BslLsReport.parse(SAMPLE), "MyProject", null, //$NON-NLS-1$ + new File("."), null, null, null, "Magic", 100); //$NON-NLS-1$ //$NON-NLS-2$ + assertFalse("MagicNumber row must be filtered out by excludeRule=Magic", //$NON-NLS-1$ + md.contains("diagnostics/MagicNumber")); //$NON-NLS-1$ + assertTrue("UnusedLocalVariable must survive an unrelated excludeRule", //$NON-NLS-1$ + md.contains("diagnostics/UnusedLocalVariable")); //$NON-NLS-1$ + } + @Test public void testRenderCleanProject() { String md = CodeReviewTool.render(BslLsReport.parse(EMPTY), "MyProject", null, //$NON-NLS-1$ - new File("."), null, null, null, 100); //$NON-NLS-1$ + new File("."), null, null, null, null, 100); //$NON-NLS-1$ assertTrue(md.contains("No BSL code-quality issues found")); //$NON-NLS-1$ assertFalse("clean project must not render a table header", md.contains("| Severity |")); //$NON-NLS-1$ //$NON-NLS-2$ } + + // ==================== modulePath scoping: summary must match the filtered rows ==================== + + @Test + public void testRenderModuleScopedSummaryExcludesSiblingModuleFindings() + { + // The engine analyzes the whole containing directory, so its report also carries + // Helper.bsl's (sibling) Warning finding. The review was scoped to Module.bsl alone, so + // BOTH the summary counts and the table must reflect only Module.bsl's ONE finding. + BslLsReport report = BslLsReport.parse(SAMPLE_TWO_SIBLING_MODULES); + String targetAbsPath = findPathEnding(report, "Module.bsl"); //$NON-NLS-1$ + assertNotNull("fixture must contain a Module.bsl finding", targetAbsPath); //$NON-NLS-1$ + + String md = CodeReviewTool.render(report, "MyProject", "CommonModules/Calc/Module.bsl", //$NON-NLS-1$ //$NON-NLS-2$ + new File("."), targetAbsPath, null, null, null, 100); //$NON-NLS-1$ + + assertTrue("summary must count only the target module's finding, not its sibling's", //$NON-NLS-1$ + md.contains("**1** finding(s)")); //$NON-NLS-1$ + assertTrue(md.contains("1 information")); //$NON-NLS-1$ + assertTrue("the sibling's Warning must not inflate the scoped warning count", //$NON-NLS-1$ + md.contains("0 warning")); //$NON-NLS-1$ + assertTrue(md.contains("diagnostics/MagicNumber")); //$NON-NLS-1$ + assertFalse("the sibling module's finding must not leak into the scoped table", //$NON-NLS-1$ + md.contains("diagnostics/UnusedLocalVariable")); //$NON-NLS-1$ + } + + @Test + public void testRenderModuleScopedCleanFileAmongDirtySiblingsReportsClean() + { + // Scope to the sibling that HAS NO findings of its own (Helper.bsl has one, but we target + // a path that matches neither -> equivalent to "this exact module is clean"). The summary + // must say "No BSL code-quality issues found", not surface the sibling's non-zero total. + BslLsReport report = BslLsReport.parse(SAMPLE_TWO_SIBLING_MODULES); + String targetAbsPath = findPathEnding(report, "Module.bsl").replace("Module.bsl", "OtherClean.bsl"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + String md = CodeReviewTool.render(report, "MyProject", "CommonModules/Calc/OtherClean.bsl", //$NON-NLS-1$ //$NON-NLS-2$ + new File("."), targetAbsPath, null, null, null, 100); //$NON-NLS-1$ + + assertTrue("a module with no findings of its own must report clean, not the directory's total", //$NON-NLS-1$ + md.contains("No BSL code-quality issues found")); //$NON-NLS-1$ + assertTrue(md.contains("**0** finding(s)")); //$NON-NLS-1$ + } + + private static String findPathEnding(BslLsReport report, String suffix) + { + for (BslLsReport.Finding f : report.findings()) + { + String normalized = f.path() == null ? null : f.path().replace('\\', '/'); + if (normalized != null && normalized.endsWith(suffix)) + { + return f.path(); + } + } + return null; + } + + // ==================== modulePath scoping: must stay inside the project's own src/ ==================== + + @Test + public void testIsWithinSrcAcceptsSrcRootItself() + { + File srcRoot = new File("C:/proj/src"); //$NON-NLS-1$ + assertTrue(CodeReviewTool.isWithinSrc(srcRoot, srcRoot)); + } + + @Test + public void testIsWithinSrcAcceptsDescendantModulePath() + { + File srcRoot = new File("C:/proj/src"); //$NON-NLS-1$ + File module = new File(srcRoot, "CommonModules/Calc/Module.bsl"); //$NON-NLS-1$ + assertTrue(CodeReviewTool.isWithinSrc(srcRoot, module)); + } + + @Test + public void testIsWithinSrcRejectsAbsolutePathOutsideProject() + { + // Models an absolute modulePath resolving into a DIFFERENT project's src/ within the + // same Eclipse workspace (BslModuleUtils.resolveModuleFile resolves an absolute path + // against the whole workspace, not just the requested project). + File srcRoot = new File("C:/workspace/ProjectA/src"); //$NON-NLS-1$ + File otherProjectFile = new File("C:/workspace/ProjectB/src/CommonModules/Calc/Module.bsl"); //$NON-NLS-1$ + assertFalse(CodeReviewTool.isWithinSrc(srcRoot, otherProjectFile)); + } + + @Test + public void testIsWithinSrcRejectsDotDotTraversalEscapingSrcRoot() + { + // "../../ProjectB/src/Module.bsl" resolved relative to ProjectA/src must NOT be accepted: + // it normalizes to a location outside ProjectA's own src/ entirely. + File srcRoot = new File("C:/workspace/ProjectA/src"); //$NON-NLS-1$ + File escaping = new File(srcRoot, "../../ProjectB/src/CommonModules/Calc/Module.bsl"); //$NON-NLS-1$ + assertFalse(CodeReviewTool.isWithinSrc(srcRoot, escaping)); + } + + @Test + public void testIsWithinSrcRejectsSiblingPathWithSharedPrefix() + { + // A naive String.startsWith("C:/proj/src") would wrongly accept "C:/proj/src-evil/...". + // isWithinSrc must compare path SEGMENTS (java.nio.file.Path#startsWith), not raw strings. + File srcRoot = new File("C:/proj/src"); //$NON-NLS-1$ + File lookalike = new File("C:/proj/src-evil/Module.bsl"); //$NON-NLS-1$ + assertFalse(CodeReviewTool.isWithinSrc(srcRoot, lookalike)); + } + + @Test + public void testIsWithinSrcRejectsNullArguments() + { + assertFalse(CodeReviewTool.isWithinSrc(null, new File("C:/proj/src/Module.bsl"))); //$NON-NLS-1$ + assertFalse(CodeReviewTool.isWithinSrc(new File("C:/proj/src"), null)); //$NON-NLS-1$ + assertFalse(CodeReviewTool.isWithinSrc(null, null)); + } } From 9cc0c776aa4b63ed1d1572f4728c4a8fd826c46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B0=D1=80=D0=B0=D1=81=D0=BE=D0=B2=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Mon, 3 Aug 2026 08:42:09 +0300 Subject: [PATCH 3/8] =?UTF-8?q?BslLsRunner:=20--workspaceDir=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=B2=D0=B8=D0=B6=D0=BA=D0=B0,=20=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D1=8B=20=D0=BF=D0=BE=20=D0=BF=D0=B0=D0=BC?= =?UTF-8?q?=D1=8F=D1=82=D0=B8,=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20ja?= =?UTF-8?q?r=20=D0=BF=D0=BE=20SemVer,=20fixture-=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. --configuration у движка документирован как ГЛОБАЛЬНЫЙ слот конфигурации, а не привязанный к анализируемой директории; раньше --workspaceDir вообще не передавался. При анализе ОДНОГО модуля (--srcDir сужен до папки файла) это могло увести относительные пути отчёта и поиск workspace-local .bsl-language-server.json от корня проекта. Теперь --workspaceDir всегда передаётся явно и закреплён за src/ проекта независимо от того, насколько узок --srcDir для конкретного запуска (подтверждено через --help реального движка: --workspaceDir/-w - самостоятельный флаг analyze). 2. stdout процесса копился в StringBuilder без ограничения, и весь JSON-отчёт читался в память + строился полный DOM Gson ДО того, как OutputSizeGuard вообще получал шанс урезать финальный ответ - разросшийся или испорченный процесс движка мог исчерпать heap EDT. Добавлены: --silent движку (снижает шум в stdout), ограничение НАКОПЛЕННОГО stdout до 8000 символов (хвост обрезается по мере поступления, сам поток дочитывается полностью, чтобы не заблокировать дочерний процесс на переполненном pipe), и проверка размера файла отчёта (Files.size) ДО чтения - отчёт больше 50 МБ отклоняется явной ошибкой вместо чтения в память. 3. Выбор jar по лексикографическому сравнению имени файла давал неверный результат для "roughly newest": "...-1.9.0-exec.jar" лексически больше "...-1.10.0-exec.jar" ('9' > '1'), из-за чего мог тихо выбираться СТАРЫЙ релиз - особенно опасно, учитывая что две заявленные major-ветки (0.28.x и 1.x) требуют разных версий Java. Добавлено числовое сравнение версии компонент-за-компонентом (extractVersion/compareVersions/ compareJarVersions), с тестами на 0.28/1.9/1.10 и на нечисловой pre-release-суффикс. 4. Все прежние unit-тесты проверяли только резолвинг (какой jar/Java/config), а не сам процесс: реальный e2e с движком просто SKIP'ается, если jar не установлен - код запуска подпроцесса не проходил в CI вообще ни разу. Добавлен компилируемый на лету "fixture jar" (через ToolProvider.getSystemJavaCompiler + JarOutputStream, без нового Maven-модуля), имитирующий движок по сценарию (обычный отчёт / огромный stdout / огромный отчёт) - тесты гоняют реальный BslLsRunner.run через реальный дочерний процесс и проверяют его сквозную обработку без многосотмегабайтного настоящего jar. Проверено: mvn clean verify -> BUILD SUCCESS, 4105 тестов (fixture-тесты реально выполняются, не skip - подтверждено по surefire-отчёту). --- .../edt/mcp/server/utils/BslLsRunner.java | 207 +++++++++- .../edt/mcp/server/utils/BslLsRunnerTest.java | 357 +++++++++++++++++- 2 files changed, 553 insertions(+), 11 deletions(-) diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java index 75e5a61d7..cdb148c0a 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java @@ -36,7 +36,10 @@ * Java 21; {@code 0.28.x} runs on Java 17. * * The configuration of which checks run is the engine's own - * {@code .bsl-language-server.json} (see {@link Request#configFile}). + * {@code .bsl-language-server.json} (see {@link Request#configFile}); {@code --workspaceDir} + * (see {@link Request#workspaceDir}) pins the project root the engine scopes that + * configuration and its report paths to, independent of how narrow {@code --srcDir} is + * for a given run (see {@link #buildCommand}). */ public final class BslLsRunner { @@ -52,6 +55,26 @@ public final class BslLsRunner private static final String REPORT_FILE = "bsl-json.json"; //$NON-NLS-1$ private static final int DEFAULT_TIMEOUT_SECONDS = 180; + /** + * Bound on the engine's captured stdout+stderr (merged via + * {@link ProcessBuilder#redirectErrorStream}) kept in memory WHILE the process runs. Only the + * last {@link #tail}-sized slice of this is ever shown to a caller (in the "no report produced" + * error), so retaining more than this was pure waste that could exhaust the EDT heap against a + * runaway or pathologically chatty subprocess. {@link #drainAsync} trims the front once this is + * exceeded — the stream itself is still fully drained (never blocking the child on a full pipe + * buffer), only what is RETAINED is bounded. + */ + static final int MAX_CAPTURED_OUTPUT_CHARS = 8_000; + + /** + * Bound on the engine's JSON report file size, checked BEFORE it is read into memory. A report + * this large indicates a pathological/misconfigured run (or a corrupt engine process) — reading + * it fully via {@link Files#readAllBytes} and then having Gson build a full DOM over it could + * exhaust the EDT heap long before {@code OutputSizeGuard} ever gets a chance to cap the FINAL + * response text (that guard only bounds the rendered Markdown, not this intermediate JSON). + */ + static final long MAX_REPORT_BYTES = 50_000_000L; + private BslLsRunner() { } @@ -63,6 +86,7 @@ private BslLsRunner() public static final class Request { private final File srcDir; + private File workspaceDir; private File configFile; private File jarOverride; private File javaOverride; @@ -77,6 +101,21 @@ public Request(File srcDir) this.srcDir = srcDir; } + /** + * @param dir the project's own workspace root (conventionally the directory that + * hosts its {@code .bsl-language-server.json}, e.g. the project's + * {@code src} folder) — passed to the engine as {@code --workspaceDir} + * so report paths and workspace-local settings stay scoped to THIS + * project even when {@link #srcDir} is narrowed to a single module's + * containing folder. When {@code null}, {@link #srcDir} itself is used + * @return this request + */ + public Request workspaceDir(File dir) + { + this.workspaceDir = dir; + return this; + } + /** * @param file the project's {@code .bsl-language-server.json}; when {@code null} * or absent the engine-home config (next to the jar) is used, else @@ -215,7 +254,29 @@ public static Result run(Request request) } } - private static Result execute(File java, File jar, File config, Request request, Path outputDir) + /** + * Builds the engine CLI invocation. Pure/side-effect-free (no process launched), so + * it is directly unit-testable. + *

+ * {@code --workspaceDir} is always passed explicitly (never left to the engine's own + * default, which is its process CWD): it is the project's own workspace root, kept + * stable across a whole-project run and a single-module run (where {@code --srcDir} + * narrows to just the module's containing folder). This matters because the CLI's + * {@code --configuration}/{@code -c} flag is documented upstream as populating the + * engine's global configuration slot (searched, when omitted, via the process + * CWD then the user's home directory) — it is not itself workspace-scoped. Pinning + * {@code --workspaceDir} to the project root keeps report path relativization and any + * workspace-local {@code .bsl-language-server.json} discovery tied to THIS project, + * regardless of how narrow {@code --srcDir} is for this particular run. + * + * @param java the resolved java(.exe) launcher + * @param jar the resolved engine jar + * @param config the resolved configuration file, or {@code null} to omit {@code --configuration} + * @param request the run inputs ({@link Request#srcDir} and optional {@link Request#workspaceDir}) + * @param outputDir the temp directory the engine writes its report into + * @return the full command line, ready for {@link ProcessBuilder} + */ + static List buildCommand(File java, File jar, File config, Request request, Path outputDir) { List command = new ArrayList<>(); command.add(java.getAbsolutePath()); @@ -225,15 +286,35 @@ private static Result execute(File java, File jar, File config, Request request, command.add("--analyze"); //$NON-NLS-1$ command.add("--srcDir"); //$NON-NLS-1$ command.add(request.srcDir.getAbsolutePath()); + command.add("--workspaceDir"); //$NON-NLS-1$ + command.add(resolveWorkspaceDir(request).getAbsolutePath()); command.add("--outputDir"); //$NON-NLS-1$ command.add(outputDir.toString()); command.add("--reporter"); //$NON-NLS-1$ command.add("json"); //$NON-NLS-1$ + command.add("--silent"); //$NON-NLS-1$ if (config != null) { command.add("--configuration"); //$NON-NLS-1$ command.add(config.getAbsolutePath()); } + return command; + } + + /** + * @param request the run inputs + * @return {@link Request#workspaceDir} when set, else {@link Request#srcDir} (so a + * caller that does not care about the whole-project/single-module distinction + * keeps today's behaviour of scoping the workspace to the analyzed directory) + */ + static File resolveWorkspaceDir(Request request) + { + return request.workspaceDir != null ? request.workspaceDir : request.srcDir; + } + + private static Result execute(File java, File jar, File config, Request request, Path outputDir) + { + List command = buildCommand(java, jar, config, request, outputDir); ProcessBuilder pb = new ProcessBuilder(command); // Working directory MUST share a filesystem root with the analyzed sources: the engine @@ -287,6 +368,26 @@ private static Result execute(File java, File jar, File config, Request request, + "Engine output: " + tail(captured.toString())); //$NON-NLS-1$ } + // Checked BEFORE any read: a pathologically large report must not be pulled fully into + // memory (then handed to Gson to build a full DOM over) just to eventually get truncated by + // OutputSizeGuard on the rendered response text - fail loud instead, with the same "narrow + // the scope" guidance the timeout error gives. + long reportSize; + try + { + reportSize = Files.size(reportPath); + } + catch (IOException e) + { + return Result.error("Could not read the BSL Language Server report: " + e.getMessage()); //$NON-NLS-1$ + } + if (reportSize > MAX_REPORT_BYTES) + { + return Result.error("BSL Language Server report is " + reportSize + " bytes, over the " //$NON-NLS-1$ //$NON-NLS-2$ + + MAX_REPORT_BYTES + "-byte limit; not read into memory. Narrow the scope (pass a " //$NON-NLS-1$ + + "modulePath) and re-run."); //$NON-NLS-1$ + } + String json; try { @@ -328,7 +429,7 @@ static File resolveJar(File override) return scanned; } - private static File scanForExecJar(File dir) + static File scanForExecJar(File dir) { if (dir == null || !dir.isDirectory()) { @@ -340,11 +441,16 @@ private static File scanForExecJar(File dir) { return null; } - // Prefer the lexicographically largest name (roughly the newest version). + // Prefer the NEWEST version, comparing the embedded version NUMERICALLY component-by- + // component (see compareJarVersions) - a plain filename compareTo is lexicographic, which + // gets this backwards ("...-1.9.0-exec.jar" sorts AFTER "...-1.10.0-exec.jar", silently + // keeping the OLDER of two releases). This matters especially here: the two claimed major + // lines (0.28.x / 1.x) need DIFFERENT Java versions to run, so picking the wrong one is not + // just "an older version" but potentially a launch failure. File best = jars[0]; for (File j : jars) { - if (j.getName().compareTo(best.getName()) > 0) + if (compareJarVersions(j.getName(), best.getName()) > 0) { best = j; } @@ -352,6 +458,85 @@ private static File scanForExecJar(File dir) return best; } + /** + * Compares two {@code bsl-language-server--exec.jar} filenames by their embedded + * version (see {@link #compareVersions}). When a version cannot be extracted from EITHER name + * (an unexpected filename shape slipped past the {@link #scanForExecJar} glob), falls back to a + * plain filename comparison so scanning still terminates deterministically rather than throwing. + * + * @return negative/zero/positive as {@code nameA}'s version is older/equal/newer than {@code nameB}'s + */ + static int compareJarVersions(String nameA, String nameB) + { + String va = extractVersion(nameA); + String vb = extractVersion(nameB); + if (va == null || vb == null) + { + return nameA.compareTo(nameB); + } + return compareVersions(va, vb); + } + + /** + * @param fileName a candidate exec-jar filename + * @return the dotted version substring between the {@code "bsl-language-server-"} prefix and + * the {@code "-exec.jar"} suffix (e.g. {@code "1.10.0"} from + * {@code "bsl-language-server-1.10.0-exec.jar"}), or {@code null} when the name does not + * have that shape + */ + static String extractVersion(String fileName) + { + String prefix = "bsl-language-server-"; //$NON-NLS-1$ + String suffix = "-exec.jar"; //$NON-NLS-1$ + if (fileName == null || !fileName.startsWith(prefix) || !fileName.endsWith(suffix) + || fileName.length() < prefix.length() + suffix.length()) + { + return null; + } + return fileName.substring(prefix.length(), fileName.length() - suffix.length()); + } + + /** + * Compares two dotted version strings (e.g. {@code "0.28.0"}, {@code "1.10.0"}) NUMERICALLY, + * component by component - NOT lexicographically, where {@code "1.9.0"} would wrongly sort + * after {@code "1.10.0"}. A version with fewer components is padded with {@code 0} for the + * comparison (so {@code "1.9"} == {@code "1.9.0"}). A non-numeric component (e.g. a pre-release + * suffix glued onto the last segment, like {@code "0-rc1"}) falls back to a plain string + * comparison for just THAT component - full SemVer pre-release precedence is not implemented, + * this only needs to stay deterministic and not throw on the rare pre-release jar name. + * + * @return negative/zero/positive as {@code a} is older/equal/newer than {@code b} + */ + static int compareVersions(String a, String b) + { + String[] pa = a.split("\\."); //$NON-NLS-1$ + String[] pb = b.split("\\."); //$NON-NLS-1$ + int n = Math.max(pa.length, pb.length); + for (int i = 0; i < n; i++) + { + String sa = i < pa.length ? pa[i] : "0"; //$NON-NLS-1$ + String sb = i < pb.length ? pb[i] : "0"; //$NON-NLS-1$ + int cmp = compareVersionComponent(sa, sb); + if (cmp != 0) + { + return cmp; + } + } + return 0; + } + + private static int compareVersionComponent(String sa, String sb) + { + try + { + return Integer.compare(Integer.parseInt(sa), Integer.parseInt(sb)); + } + catch (NumberFormatException e) + { + return sa.compareTo(sb); + } + } + /** * Resolves the Java launcher: explicit override, then {@link #ENV_JAVA}, then the * JRE running EDT ({@code java.home}). Returns {@code null} only if none resolves to @@ -417,6 +602,14 @@ private static String javaNotFoundMessage() + " to a java executable (Java 21+ for the 1.x engine, Java 17 for 0.28.x)."; //$NON-NLS-1$ } + /** + * Drains the process's merged stdout+stderr on a background thread so the child never blocks + * on a full OS pipe buffer, while keeping {@code sink}'s RETAINED size bounded to + * {@link #MAX_CAPTURED_OUTPUT_CHARS}: the stream is read in full regardless (every line is + * consumed), but once the buffer exceeds the cap its FRONT is trimmed, so a subprocess that + * produces gigabytes of chatter (or loops printing) cannot grow this buffer without bound — only + * the tail is ever shown to a caller anyway (see {@link #tail}). + */ private static Thread drainAsync(Process process, StringBuilder sink) { Thread t = new Thread(() -> { @@ -429,6 +622,10 @@ private static Thread drainAsync(Process process, StringBuilder sink) synchronized (sink) { sink.append(line).append('\n'); + if (sink.length() > MAX_CAPTURED_OUTPUT_CHARS) + { + sink.delete(0, sink.length() - MAX_CAPTURED_OUTPUT_CHARS); + } } } } diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java index 00e9bff26..4902d13ca 100644 --- a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java @@ -13,22 +13,38 @@ import static org.junit.Assert.assertTrue; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; +import java.util.List; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; import org.junit.After; +import org.junit.Assume; import org.junit.Before; import org.junit.Test; /** - * Tests for {@link BslLsRunner}'s resolution logic — the parts that decide WHICH jar, - * Java and configuration file are used, without spawning the engine. The subprocess - * path itself is validated live/e2e (it needs the real jar + a Java 21). These tests - * cover the deterministic, side-effect-free resolution rules on temp files; they - * avoid asserting the env-var branch because the ambient environment differs between - * the developer machine (where {@code EDT_MCP_BSL_LS_*} are set) and CI. + * Tests for {@link BslLsRunner}: the resolution logic (which jar/Java/config is used) AND, via a + * small compiled-on-the-fly "fixture jar" standing in for the real engine (see + * {@link #buildFixtureJar()}), the subprocess PLUMBING itself — report discovery under + * {@code --outputDir}, stdout-capture bounding, and the oversized-report guard. This closes a real + * gap: the actual BSL Language Server engine's happy-path e2e tests SKIP entirely when its + * multi-hundred-MB jar is not installed, so without a fixture the subprocess code path in + * {@link BslLsRunner#run} was never exercised in CI at all — only the pure resolution rules were. + * The engine's own DIAGNOSTIC correctness (what rules it reports, on real BSL source) still needs + * the real jar and stays live/e2e; the fixture only proves the runner's OWN process handling. + *

+ * The resolution tests avoid asserting the env-var branch because the ambient environment differs + * between the developer machine (where {@code EDT_MCP_BSL_LS_*} are set) and CI. *

* Uses {@code java.nio.file} temp dirs directly rather than JUnit's * {@code TemporaryFolder}, which the Tycho target platform treats as non-API. @@ -151,6 +167,335 @@ public void testRunRejectsNullRequest() assertNotNull(result.errorMessage()); } + // ==================== Command-line construction (finding: --workspaceDir scoping) ==================== + + @Test + public void testBuildCommandPassesWorkspaceDirWhenSet() throws IOException + { + File javaExe = newFile("java.exe"); + File jar = newFile("bsl-language-server-1.0.3-exec.jar"); + File srcDir = newFolder("module-folder"); + File workspaceRoot = newFolder("project-src-root"); + Path outputDir = root.resolve("out"); + + BslLsRunner.Request request = new BslLsRunner.Request(srcDir).workspaceDir(workspaceRoot); + List command = BslLsRunner.buildCommand(javaExe, jar, null, request, outputDir); + + assertTrue("--workspaceDir must be passed explicitly", command.contains("--workspaceDir")); + int workspaceIdx = command.indexOf("--workspaceDir"); + assertEquals("--workspaceDir must point at the project's own workspace root, not the " + + "(possibly narrower) analyzed --srcDir", workspaceRoot.getAbsolutePath(), command.get(workspaceIdx + 1)); + + int srcDirIdx = command.indexOf("--srcDir"); + assertTrue("--srcDir must still be passed", srcDirIdx >= 0); + assertEquals(srcDir.getAbsolutePath(), command.get(srcDirIdx + 1)); + // The two must differ here: this is exactly the single-module-narrows-srcDir case + // --workspaceDir must stay pinned against. + assertFalse(command.get(srcDirIdx + 1).equals(command.get(workspaceIdx + 1))); + } + + @Test + public void testBuildCommandDefaultsWorkspaceDirToSrcDirWhenUnset() throws IOException + { + File javaExe = newFile("java.exe"); + File jar = newFile("bsl-language-server-1.0.3-exec.jar"); + File srcDir = newFolder("whole-project-src"); + Path outputDir = root.resolve("out"); + + BslLsRunner.Request request = new BslLsRunner.Request(srcDir); + List command = BslLsRunner.buildCommand(javaExe, jar, null, request, outputDir); + + int workspaceIdx = command.indexOf("--workspaceDir"); + assertTrue("--workspaceDir must be passed even without an explicit override", workspaceIdx >= 0); + assertEquals(srcDir.getAbsolutePath(), command.get(workspaceIdx + 1)); + } + + @Test + public void testBuildCommandIncludesConfigurationWhenConfigResolved() throws IOException + { + File javaExe = newFile("java.exe"); + File jar = newFile("bsl-language-server-1.0.3-exec.jar"); + File srcDir = newFolder("src"); + File config = newFile(".bsl-language-server.json"); + Path outputDir = root.resolve("out"); + + BslLsRunner.Request request = new BslLsRunner.Request(srcDir); + List command = BslLsRunner.buildCommand(javaExe, jar, config, request, outputDir); + + int configIdx = command.indexOf("--configuration"); + assertTrue("--configuration must be passed when a config file resolved", configIdx >= 0); + assertEquals(config.getAbsolutePath(), command.get(configIdx + 1)); + } + + @Test + public void testBuildCommandOmitsConfigurationWhenNoneResolved() throws IOException + { + File javaExe = newFile("java.exe"); + File jar = newFile("bsl-language-server-1.0.3-exec.jar"); + File srcDir = newFolder("src-no-config"); + Path outputDir = root.resolve("out"); + + BslLsRunner.Request request = new BslLsRunner.Request(srcDir); + List command = BslLsRunner.buildCommand(javaExe, jar, null, request, outputDir); + + assertFalse("--configuration must be omitted, not passed with a null path", + command.contains("--configuration")); + } + + @Test + public void testResolveWorkspaceDirPrefersExplicitOverride() throws IOException + { + File srcDir = newFolder("scoped-dir"); + File workspaceRoot = newFolder("project-root"); + BslLsRunner.Request request = new BslLsRunner.Request(srcDir).workspaceDir(workspaceRoot); + assertEquals(workspaceRoot, BslLsRunner.resolveWorkspaceDir(request)); + } + + @Test + public void testResolveWorkspaceDirFallsBackToSrcDir() throws IOException + { + File srcDir = newFolder("scoped-dir-2"); + BslLsRunner.Request request = new BslLsRunner.Request(srcDir); + assertEquals(srcDir, BslLsRunner.resolveWorkspaceDir(request)); + } + + // ==================== Jar version comparison (finding: lexicographic sort picks the wrong "newest") ==================== + + @Test + public void testCompareVersionsMinorVersionNumericNotLexicographic() + { + // The exact bug: "1.9.0" lexicographically sorts AFTER "1.10.0" ('9' > '1'), which would + // keep the OLDER jar. Numeric comparison must get this the other way round. + assertTrue("1.10.0 must be newer than 1.9.0", BslLsRunner.compareVersions("1.10.0", "1.9.0") > 0); + assertTrue("1.9.0 must be older than 1.10.0", BslLsRunner.compareVersions("1.9.0", "1.10.0") < 0); + } + + @Test + public void testCompareVersionsAcrossMajorLines() + { + assertTrue("1.0.0 must be newer than 0.28.0 (the two claimed major lines)", + BslLsRunner.compareVersions("1.0.0", "0.28.0") > 0); + } + + @Test + public void testCompareVersionsEqualPadsMissingComponentsWithZero() + { + assertEquals("1.9 must equal 1.9.0 (missing trailing component defaults to 0)", + 0, BslLsRunner.compareVersions("1.9", "1.9.0")); + } + + @Test + public void testCompareVersionsIdentical() + { + assertEquals(0, BslLsRunner.compareVersions("1.10.0", "1.10.0")); + } + + @Test + public void testCompareVersionsPreReleaseSuffixDoesNotThrow() + { + // Full SemVer pre-release precedence is not implemented (see the method's javadoc) - this + // only needs to stay deterministic and not throw on a non-numeric trailing component. + int result = BslLsRunner.compareVersions("1.10.0-rc1", "1.10.0"); + assertEquals("the same comparison must be stable across repeated calls", + result, BslLsRunner.compareVersions("1.10.0-rc1", "1.10.0")); + } + + @Test + public void testExtractVersionParsesTheExpectedShape() + { + assertEquals("1.10.0", BslLsRunner.extractVersion("bsl-language-server-1.10.0-exec.jar")); + assertEquals("0.28.0", BslLsRunner.extractVersion("bsl-language-server-0.28.0-exec.jar")); + } + + @Test + public void testExtractVersionRejectsUnexpectedShape() + { + assertNull(BslLsRunner.extractVersion("some-other-tool-1.0.0-exec.jar")); + assertNull(BslLsRunner.extractVersion("bsl-language-server-1.10.0.jar")); + assertNull(BslLsRunner.extractVersion(null)); + } + + @Test + public void testCompareJarVersionsPicksNewerMinorNotLexicographic() + { + assertTrue("bsl-language-server-1.10.0-exec.jar must outrank ...-1.9.0-exec.jar", + BslLsRunner.compareJarVersions("bsl-language-server-1.10.0-exec.jar", + "bsl-language-server-1.9.0-exec.jar") > 0); + } + + @Test + public void testCompareJarVersionsFallsBackToFilenameWhenUnparseable() + { + // Neither name matches the expected shape - must still return a deterministic result + // rather than throwing. + int result = BslLsRunner.compareJarVersions("weird-a.jar", "weird-b.jar"); + assertEquals("weird-a.jar".compareTo("weird-b.jar"), result); + } + + @Test + public void testScanForExecJarPicksNumericallyNewestNotLexicographicallyLargest() throws IOException + { + File dir = newFolder("multi-version-engine"); + assertTrue(new File(dir, "bsl-language-server-1.9.0-exec.jar").createNewFile()); + assertTrue(new File(dir, "bsl-language-server-1.10.0-exec.jar").createNewFile()); + assertTrue(new File(dir, "bsl-language-server-0.28.0-exec.jar").createNewFile()); + + File best = BslLsRunner.scanForExecJar(dir); + + assertNotNull(best); + assertEquals("bsl-language-server-1.10.0-exec.jar", best.getName()); + } + + @Test + public void testScanForExecJarReturnsNullWhenDirectoryHasNoMatch() throws IOException + { + File dir = newFolder("empty-engine-dir"); + assertNull(BslLsRunner.scanForExecJar(dir)); + } + + // ==================== Managed fixture process: real subprocess plumbing without the real engine ==================== + + @Test + public void testFixtureRunProducesAParseableReport() throws Exception + { + File fixtureJar = buildFixtureJar(); + Assume.assumeTrue("no system Java compiler available to build the fixture jar " //$NON-NLS-1$ + + "(a JRE-only test runtime?) - skip", fixtureJar != null); //$NON-NLS-1$ + File srcDir = newFolder("fixture-src-normal"); //$NON-NLS-1$ + + BslLsRunner.Request request = new BslLsRunner.Request(srcDir) + .jarOverride(fixtureJar).javaOverride(currentJavaExecutable()); + BslLsRunner.Result result = BslLsRunner.run(request); + + assertTrue("fixture run must succeed: " + (result.ok() ? "" : result.errorMessage()), result.ok()); //$NON-NLS-1$ //$NON-NLS-2$ + assertNotNull(result.report()); + } + + @Test + public void testFixtureRunWithHugeStdoutStillSucceedsWithBoundedCapture() throws Exception + { + // The fixture prints ~10 MB of stdout (well over MAX_CAPTURED_OUTPUT_CHARS) THEN writes a + // normal small report - proving the bounded drain neither blocks the child (it must still + // finish and exit) nor breaks the actual report handling, while never retaining the full + // 10 MB in the runner's own memory. + File fixtureJar = buildFixtureJar(); + Assume.assumeTrue("no system Java compiler available to build the fixture jar - skip", //$NON-NLS-1$ + fixtureJar != null); + File srcDir = newFolder("fixture-src-huge-stdout"); //$NON-NLS-1$ + Files.write(srcDir.toPath().resolve("FIXTURE_BEHAVIOR.txt"), "huge-stdout".getBytes()); //$NON-NLS-1$ //$NON-NLS-2$ + + BslLsRunner.Request request = new BslLsRunner.Request(srcDir) + .jarOverride(fixtureJar).javaOverride(currentJavaExecutable()).timeoutSeconds(60); + BslLsRunner.Result result = BslLsRunner.run(request); + + assertTrue("a huge-stdout child must still complete and parse: " //$NON-NLS-1$ + + (result.ok() ? "" : result.errorMessage()), result.ok()); //$NON-NLS-1$ + } + + @Test + public void testFixtureRunWithOversizedReportFailsLoudWithoutReadingIt() throws Exception + { + // The fixture writes a ~60 MB report (over MAX_REPORT_BYTES) - run() must reject it with an + // actionable error BEFORE attempting Files.readAllBytes/Gson-parsing it. + File fixtureJar = buildFixtureJar(); + Assume.assumeTrue("no system Java compiler available to build the fixture jar - skip", //$NON-NLS-1$ + fixtureJar != null); + File srcDir = newFolder("fixture-src-huge-report"); //$NON-NLS-1$ + Files.write(srcDir.toPath().resolve("FIXTURE_BEHAVIOR.txt"), "huge-report".getBytes()); //$NON-NLS-1$ //$NON-NLS-2$ + + BslLsRunner.Request request = new BslLsRunner.Request(srcDir) + .jarOverride(fixtureJar).javaOverride(currentJavaExecutable()).timeoutSeconds(60); + BslLsRunner.Result result = BslLsRunner.run(request); + + assertFalse("an oversized report must be rejected, not silently parsed", result.ok()); //$NON-NLS-1$ + assertNotNull(result.errorMessage()); + assertTrue("the error must name the byte limit that was exceeded: " + result.errorMessage(), //$NON-NLS-1$ + result.errorMessage().contains(String.valueOf(BslLsRunner.MAX_REPORT_BYTES))); + } + + /** @return the {@code java(.exe)} launching THIS test JVM, for spawning the fixture jar. */ + private static File currentJavaExecutable() + { + File bin = new File(System.getProperty("java.home"), "bin"); //$NON-NLS-1$ //$NON-NLS-2$ + boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + return new File(bin, windows ? "java.exe" : "java"); //$NON-NLS-1$ //$NON-NLS-2$ + } + + /** + * Compiles and packages a tiny, REAL runnable jar standing in for the BSL Language Server engine + * ({@code --analyze --srcDir --workspaceDir --outputDir --reporter json --silent + * [--configuration ]}, exactly {@link BslLsRunner#buildCommand}'s shape): it locates + * {@code --srcDir}/{@code --outputDir} among its own args, reads an optional + * {@code FIXTURE_BEHAVIOR.txt} sentinel file from {@code --srcDir} (defaulting to + * {@code "normal"} when absent) and either writes a small valid {@code bsl-json.json}, + * floods stdout, or writes an oversized report, before exiting 0. Compiled in-process via + * {@link ToolProvider#getSystemJavaCompiler()} (needs a JDK, not a bare JRE, running the test) so + * no extra Maven module/dependency is needed just for this fixture. + * + * @return the built fixture jar, or {@code null} when no system Java compiler is available (the + * calling test then skips via {@link Assume}) + */ + private File buildFixtureJar() throws IOException + { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) + { + return null; + } + File work = newFolder("fixture-build-" + System.nanoTime()); //$NON-NLS-1$ + File sourceFile = new File(work, "Fixture.java"); //$NON-NLS-1$ + String source = "public class Fixture {\n" //$NON-NLS-1$ + + " public static void main(String[] args) throws Exception {\n" //$NON-NLS-1$ + + " String srcDir = null, outputDir = null;\n" //$NON-NLS-1$ + + " for (int i = 0; i < args.length - 1; i++) {\n" //$NON-NLS-1$ + + " if (\"--srcDir\".equals(args[i])) srcDir = args[i + 1];\n" //$NON-NLS-1$ + + " if (\"--outputDir\".equals(args[i])) outputDir = args[i + 1];\n" //$NON-NLS-1$ + + " }\n" //$NON-NLS-1$ + + " java.io.File behaviorFile = new java.io.File(srcDir, \"FIXTURE_BEHAVIOR.txt\");\n" //$NON-NLS-1$ + + " String behavior = behaviorFile.isFile()\n" //$NON-NLS-1$ + + " ? new String(java.nio.file.Files.readAllBytes(behaviorFile.toPath())).trim() : \"normal\";\n" //$NON-NLS-1$ + + " java.io.File report = new java.io.File(outputDir, \"bsl-json.json\");\n" //$NON-NLS-1$ + + " if (\"huge-stdout\".equals(behavior)) {\n" //$NON-NLS-1$ + + " StringBuilder line = new StringBuilder();\n" //$NON-NLS-1$ + + " for (int i = 0; i < 2000; i++) line.append('x');\n" //$NON-NLS-1$ + + " for (int i = 0; i < 5000; i++) System.out.println(line);\n" //$NON-NLS-1$ + + " java.nio.file.Files.write(report.toPath(), \"{\\\"fileinfos\\\":[]}\".getBytes());\n" //$NON-NLS-1$ + + " } else if (\"huge-report\".equals(behavior)) {\n" //$NON-NLS-1$ + + " java.io.FileOutputStream out = new java.io.FileOutputStream(report);\n" //$NON-NLS-1$ + + " out.write(\"{\\\"fileinfos\\\":[{\\\"path\\\":\\\"x\\\",\\\"diagnostics\\\":[],\\\"pad\\\":\\\"\".getBytes());\n" //$NON-NLS-1$ + + " byte[] chunk = new byte[1_000_000];\n" //$NON-NLS-1$ + + " java.util.Arrays.fill(chunk, (byte) 'x');\n" //$NON-NLS-1$ + + " for (int i = 0; i < 60; i++) out.write(chunk);\n" //$NON-NLS-1$ + + " out.write(\"\\\"}]}\".getBytes());\n" //$NON-NLS-1$ + + " out.close();\n" //$NON-NLS-1$ + + " } else {\n" //$NON-NLS-1$ + + " java.nio.file.Files.write(report.toPath(), \"{\\\"fileinfos\\\":[]}\".getBytes());\n" //$NON-NLS-1$ + + " }\n" //$NON-NLS-1$ + + " System.exit(0);\n" //$NON-NLS-1$ + + " }\n" //$NON-NLS-1$ + + "}\n"; //$NON-NLS-1$ + Files.write(sourceFile.toPath(), source.getBytes()); + + int compileResult = compiler.run(null, null, null, sourceFile.getPath()); + if (compileResult != 0) + { + throw new IOException("Failed to compile the test fixture (exit " + compileResult + ")"); //$NON-NLS-1$ //$NON-NLS-2$ + } + + File classFile = new File(work, "Fixture.class"); //$NON-NLS-1$ + File jarFile = new File(work, "fixture-exec.jar"); //$NON-NLS-1$ + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); //$NON-NLS-1$ + manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, "Fixture"); //$NON-NLS-1$ + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jarFile), manifest)) + { + jos.putNextEntry(new JarEntry("Fixture.class")); //$NON-NLS-1$ + jos.write(Files.readAllBytes(classFile.toPath())); + jos.closeEntry(); + } + return jarFile; + } + private File newFile(String name) throws IOException { File f = new File(root.toFile(), name); From 2f967849c6e973f292e88e0e2ec7afe8918fbaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B0=D1=80=D0=B0=D1=81=D0=BE=D0=B2=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Thu, 6 Aug 2026 09:56:02 +0300 Subject: [PATCH 4/8] =?UTF-8?q?code=5Freview:=20SemVer=20precedence,=20?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B9=20exit-=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4,=20ToolGroup=20=E2=80=94=20=D0=BF=D0=BB=D1=8E=D1=81=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B9=D0=B4=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=B6?= =?UTF-8?q?=D0=B8=D0=B2=D1=8B=D0=BC=20e2e=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B0=20CWD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мерж origin/master (апстрим ушёл далеко вперёд после последнего пуша). Разбор всех 9 замечаний ревью показал: 6 из них уже были закрыты в предыдущих коммитах ветки (path-эскейп из modulePath, ограничение чтения stdout/отчёта, честная переформулировка контракта "это не строгая дельта над get_project_errors" + excludeRule, инфраструктура fixture-процесса для тестов раннера). Проверил каждое эмпирически, а не по сообщению коммита. Реально открытыми остались: - SemVer precedence для pre-release суффиксов (перепроверка Дитрикса): числовая часть была исправлена раньше, но "1.10.0-rc1" всё ещё сравнивался как строка и оказывался БОЛЬШЕ "1.10.0" — автовыбор жал бы RC поверх стабильного релиза той же версии. Реализована настоящая precedence: релиз без суффикса всегда старше любого pre-release той же версии; сравнение pre-release идентификаторов через точку, числовые — численно, остальные — лексически. Тесты ровно по списку, который просил Дитрикс: rc1 < rc2, релиз > rc, числовые идентификаторы через точку (rc.9 < rc.10), интеграционный scanForExecJar со stable и RC рядом. - Доверие к exit-коду движка: процесс мог упасть (exit != 0), но если файл отчёта всё равно остался на диске, тул парсил его и рапортовал успех. Эмпирически проверил на реальном движке: чистый прогон всегда exit=0, даже когда находит диагностики — так что ненулевой код это гарантированно операционный сбой, а не находки. Добавлена проверка exit перед доверием отчёту + fixture-тест. - code_review отсутствовал в ToolGroup: вкладка Tools/Disable All не могла им управлять (Toolsets — отдельный каталог для progressive disclosure, ToolGroup — для preferences UI, регистрация нужна в обоих). При живом e2e-прогоне новой логики modulePath (не из замечаний бота) поймал реальный баг: процесс запускался с CWD = узкая scopeDir модуля, а движок строит путь в отчёте из CWD + relative-фрагмент от mdoRef, а не из --srcDir — при сужении на один модуль путь задваивался в несуществующий ".../CommonModules/Calc/CommonModules/Calc/Module.bsl", который никогда не совпадал с targetAbsPath, и modulePath-сканирование молча всегда возвращало "находок нет". Починено: CWD процесса = стабильный workspaceDir (уже используется для --workspaceDir), а не узкий srcDir. Живая проверка на реальном движке подтвердила: находки появились. Golden перегенерирован (code_review — новый тул в tools/list), README и docs/tools синхронизированы командой generate_tool_docs.py (заодно обнаружилась более ранняя рассинхронизация — описание в docs всё ещё было на старой, до-excludeRule формулировке). 4348 юнит-тестов зелёные; code_review e2e 8/8 на реальном движке (bsl-language-server 1.0.3, Java 21). --- README.md | 3 +- docs/tools/README.md | 3 +- docs/tools/code_review.md | 13 +- .../edt/mcp/server/preferences/ToolGroup.java | 4 +- .../edt/mcp/server/utils/BslLsRunner.java | 156 ++++++++++++++++-- .../mcp/server/preferences/ToolGroupTest.java | 3 + .../server/tools/impl/CodeReviewToolTest.java | 9 +- .../edt/mcp/server/utils/BslLsRunnerTest.java | 131 ++++++++++++++- tests/e2e/tools_list.golden.json | 49 ++++++ 9 files changed, 345 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 144411647..cce3377c0 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ with `python docs/generate_tool_docs.py`. -**85 tools**, grouped by toolset. Full per-tool pages under [docs/tools/](docs/tools/). +**86 tools**, grouped by toolset. Full per-tool pages under [docs/tools/](docs/tools/). ### Core @@ -503,6 +503,7 @@ with `python docs/generate_tool_docs.py`. |------|-------------| | [`build_external_objects`](docs/tools/build_external_objects.md) | Build (compile to disk) the external data processors/reports of an EDT external-object project to .epf/.erf files. Build ONE object with objectName, or ALL o… | | [`clean_project`](docs/tools/clean_project.md) | Clean EDT project and trigger full revalidation. Direction: DISK -> MODEL - re-imports the on-disk src/ .mdo files into the in-memory model. Refreshes files… | +| [`code_review`](docs/tools/code_review.md) | Review BSL code quality with the BSL Language Server engine: its FULL diagnostic catalog (magic number, cyclomatic/cognitive complexity, method/line length,… | | [`create_git_branch`](docs/tools/create_git_branch.md) | Create a new local git branch, optionally check it out, and optionally attach an EXISTING infobase (application, from get_applications) to the new branch's c… | | [`create_infobase`](docs/tools/create_infobase.md) | Create a new FILE infobase (1C database) OR register an existing one, and bind it to a configuration project so it appears in get_applications. mode='create'… | | [`create_project`](docs/tools/create_project.md) | Create a NEW 1C project in the EDT workspace. projectKind selects the kind: 'configuration' (standalone), 'extension' (bound to a base configuration), or 'ex… | diff --git a/docs/tools/README.md b/docs/tools/README.md index e71c1ef71..6438f9427 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -2,7 +2,7 @@ One page per tool: what it does, every parameter, and how it works. Generated from the live server by `docs/generate_tool_docs.py` (re-run to refresh; the source of truth is each tool's Java). -**85 tools.** +**86 tools.** ## Core @@ -135,6 +135,7 @@ One page per tool: what it does, every parameter, and how it works. Generated fr |------|-------------| | [`build_external_objects`](build_external_objects.md) | Build (compile to disk) the external data processors/reports of an EDT external-object project to .epf/.erf files. Build ONE object with objectName, or ALL o… | | [`clean_project`](clean_project.md) | Clean EDT project and trigger full revalidation. Direction: DISK -> MODEL - re-imports the on-disk src/ .mdo files into the in-memory model. Refreshes files… | +| [`code_review`](code_review.md) | Review BSL code quality with the BSL Language Server engine: its FULL diagnostic catalog (magic number, cyclomatic/cognitive complexity, method/line length,… | | [`create_git_branch`](create_git_branch.md) | Create a new local git branch, optionally check it out, and optionally attach an EXISTING infobase (application, from get_applications) to the new branch's c… | | [`create_infobase`](create_infobase.md) | Create a new FILE infobase (1C database) OR register an existing one, and bind it to a configuration project so it appears in get_applications. mode='create'… | | [`create_project`](create_project.md) | Create a NEW 1C project in the EDT workspace. projectKind selects the kind: 'configuration' (standalone), 'extension' (bound to a base configuration), or 'ex… | diff --git a/docs/tools/code_review.md b/docs/tools/code_review.md index 6cac82825..e28f7da2a 100644 --- a/docs/tools/code_review.md +++ b/docs/tools/code_review.md @@ -1,6 +1,6 @@ # code_review -Review BSL code quality with the BSL Language Server engine: reports code-metric defects (magic number, cyclomatic/cognitive complexity, method/line length, nesting, …) that EDT's own checks do not cover. Each finding is a defect to FIX: it carries the rule, severity, Module path and Line, ready for read_module_source / write_module_source — fix each, then re-run code_review to verify. Scope the whole project or one module; filter by severity or rule. Needs the engine jar (see the guide). Full parameters and examples: call get_tool_guide('code_review'). +Review BSL code quality with the BSL Language Server engine: its FULL diagnostic catalog (magic number, cyclomatic/cognitive complexity, method/line length, nesting, naming, unused code, …) — this overlaps with EDT's own v8-code-style checks (get_project_errors), it is not a strict delta over them; use excludeRule to drop rule ids you already get elsewhere. Each finding is a defect to FIX: it carries the rule, severity, Module path and Line, ready for read_module_source / write_module_source — fix each, then re-run code_review to verify. Scope the whole project or one module; filter by severity, rule or excludeRule. Needs the engine jar (see the guide). Full parameters and examples: call get_tool_guide('code_review'). ## Parameters | Parameter | Required | Type | Description | @@ -9,15 +9,16 @@ Review BSL code quality with the BSL Language Server engine: reports code-metric | modulePath | — | string | Optional: narrow the review to a single module, path from src/ (e.g. 'CommonModules/Calc/Module.bsl'). Omit to review the whole configuration. | | severity | — | string (one of: error, warning, information, hint) | Optional: minimum severity to report (error > warning > information > hint). Omit to report all. | | rule | — | string | Optional: report only diagnostics whose rule id contains this substring (e.g. 'Magic', 'Complexity'). | +| excludeRule | — | string | Optional: drop diagnostics whose rule id contains this substring — e.g. to exclude rules you already get from get_project_errors and avoid double-reporting the same issue. | | limit | — | integer | Max findings; default 100, max 1000 (optional). | ## Guide Review BSL code quality by running the external BSL Language Server engine over a project (or a single module) and reporting its diagnostics as an actionable table. Every finding is a concrete defect located by `Module path` + `Line` — the same coordinates `read_module_source` and `write_module_source` use — so the intended workflow is **review → fix → re-run to verify**. ## When to use -- To surface code-metric defects EDT's own checks do not raise: magic numbers/dates, cyclomatic & cognitive complexity, method/line length, parameter counts, nesting, deprecated calls, service tags, and more. +- To run the BSL Language Server's FULL diagnostic catalog over your code: magic numbers/dates, cyclomatic & cognitive complexity, method/line length, parameter counts, nesting, deprecated calls, unused code, naming, service tags, and well over a hundred more rules. - As the first step of an automated clean-up loop: run `code_review`, fix each finding in place with `write_module_source`, then run `code_review` again (optionally scoped to the one module) to confirm the finding is gone. -- Prefer `get_project_errors` when you want EDT's configuration-development standards (`v8-code-style`) — that half is already covered there. `code_review` is the BSL Language Server metric layer on top. +- **This is NOT a strict delta over `get_project_errors`.** Both `code_review` and EDT's own `v8-code-style` (surfaced by `get_project_errors`) are BSL static analyzers with a PARTIALLY SHARED rule set, so some findings here will duplicate ones you already saw there. Use `get_project_errors` for EDT's native check surface, `code_review` for the (larger, partially different) BSL Language Server rule set, or run both to cross-check. Pass `excludeRule` to drop rule ids you already get elsewhere so they stop double-reporting. ## How the findings should be handled The rows are defects to FIX, not just a report: @@ -30,7 +31,8 @@ The rows are defects to FIX, not just a report: - `modulePath` — narrow the review to a single module, given as a path from `src/` (e.g. `CommonModules/Calc/Module.bsl`). Omit to review the whole configuration. This is the same path form the `Module path` column returns, so you can feed a row straight back in. - `severity` — minimum severity to report: `error` > `warning` > `information` > `hint`. Omit to report every severity. (These are the engine's LSP severities, independent of EDT's BLOCKER/MAJOR/… taxonomy.) - `rule` — report only diagnostics whose rule id contains this substring, case-insensitive (e.g. `Magic`, `Complexity`, `Unused`). Handy for a focused pass or a targeted re-verify. -- `limit` — maximum number of rows to render; default 100, capped at 1000. The summary counts above the table always reflect the full report, not the capped table. +- `excludeRule` — drop diagnostics whose rule id contains this substring, case-insensitive — e.g. to exclude rules you already get from `get_project_errors` and avoid reviewing the same issue twice. +- `limit` — maximum number of rows to render; default 100, capped at 1000. The summary counts above the table reflect the requested SCOPE (the whole project, or just the target module when `modulePath` narrows it) — `severity`/`rule`/`excludeRule` narrow only which rows are DISPLAYED in the table below, not the summary counts. ## Output - Markdown. A heading with the scope, a one-line summary of counts per severity, a short instruction to fix-and-re-verify, then a table with columns: `Severity`, `Rule`, `Module path`, `Line`, `Message`, `Docs` (the rule's documentation URL). @@ -52,12 +54,15 @@ The rows are defects to FIX, not just a report: - One module, fast re-verify after a fix: `{projectName: "MyProject", modulePath: "CommonModules/Calc/Module.bsl"}`. - Only the important ones: `{projectName: "MyProject", severity: "warning"}`. - Only magic numbers: `{projectName: "MyProject", rule: "Magic"}`. +- Skip a rule already covered elsewhere: `{projectName: "MyProject", excludeRule: "SemicolonPresence"}`. ## Notes & gotchas - Line numbers are 1-based (converted from the engine's 0-based LSP output), matching `read_module_source`/`set_breakpoint`. - `Module path` is relativized to `src/`; a finding outside `src/` (rare) shows its absolute path instead. - The engine analyzes files on disk. If you just edited a module through the model, ensure it is exported to disk (the write tools do this) before reviewing, or the review may read a stale file. - A large configuration can take a while to analyze; scope with `modulePath` for quick iterative checks. +- The engine's report is capped at 50 MB; a report larger than that (a pathological run, or a misconfigured/corrupt engine process) is rejected with an actionable error instead of being read into memory — narrow the scope with `modulePath` and re-run. +- A `modulePath` must resolve INSIDE the requested project's own `src/` — an absolute path or one using `..` to point elsewhere is rejected. --- *Generated from the live MCP server (`get_tool_guide`) by `docs/generate_tool_docs.py`. Do not edit this file. Edit the tool's description/schema in its Java source and its guide body in `mcp/bundles/com.ditrix.edt.mcp.server/guides/.md`.* diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/preferences/ToolGroup.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/preferences/ToolGroup.java index f4c6f80c8..7e7beaeca 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/preferences/ToolGroup.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/preferences/ToolGroup.java @@ -26,8 +26,8 @@ public enum ToolGroup "delete_project", "create_project"), //$NON-NLS-1$ //$NON-NLS-2$ PROBLEMS("problems", "Errors & Problems", //$NON-NLS-1$ //$NON-NLS-2$ - "Error reporting and workspace markers (bookmarks, tasks)", //$NON-NLS-1$ - "get_problem_summary", "get_project_errors", "get_markers"), //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + "Error reporting, workspace markers (bookmarks, tasks), and code-metric review", //$NON-NLS-1$ + "get_problem_summary", "get_project_errors", "get_markers", "code_review"), //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ CODE_INTELLIGENCE("codeIntelligence", "Code Intelligence", //$NON-NLS-1$ //$NON-NLS-2$ "Content assist, documentation, metadata browsing, and references", //$NON-NLS-1$ diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java index cdb148c0a..e24bb821d 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java @@ -320,9 +320,21 @@ private static Result execute(File java, File jar, File config, Request request, // Working directory MUST share a filesystem root with the analyzed sources: the engine // relativizes each source file against the process CWD (getFileInfoFromFile), which throws // "'other' has different root" when CWD and the sources are on different drives (e.g. a temp - // dir on C: vs a project on D:, common on Windows). The scope dir is always under the project, - // so use it as CWD; the outputDir stays an absolute path and may live on any drive. - pb.directory(request.srcDir); + // dir on C: vs a project on D:, common on Windows). resolveWorkspaceDir(request) is always + // an ANCESTOR of (or equal to) request.srcDir, so it shares the same root and satisfies that + // constraint just as well as srcDir itself would. + //

+ // It must NOT be request.srcDir directly, though - verified against the real engine: each + // finding's reported "path" is built from the process CWD joined with a metadata-relative + // fragment (e.g. "CommonModules/Calc/Module.bsl", derived from the module's mdoRef), NOT + // from --srcDir. For a single-module review request.srcDir narrows to that module's OWN + // containing folder (".../src/CommonModules/Calc") - using it as CWD makes the engine + // report a DOUBLED, non-existent path (".../src/CommonModules/Calc/CommonModules/Calc/ + // Module.bsl"), which then can never match CodeReviewTool's targetAbsPath and silently + // empties every module-scoped review. Using the STABLE workspace root as CWD instead (the + // same one --workspaceDir already pins, see buildCommand) keeps the reported path correct + // regardless of how narrow --srcDir is for a given run. + pb.directory(resolveWorkspaceDir(request)); pb.redirectErrorStream(true); Process process; @@ -361,10 +373,21 @@ private static Result execute(File java, File jar, File config, Request request, join(drain); int exit = process.exitValue(); + // Checked BEFORE trusting any report the process may have left behind: a clean analyze run + // exits 0 REGARDLESS of how many diagnostics it found (verified against the real engine - + // findings alone never produce a non-zero exit), so a non-zero exit means an operational + // failure (a crash, a bad CLI arg, an unreadable --configuration) - a stray or partially + // written bsl-json.json from such a run must not be parsed and reported as success. + if (exit != 0) + { + return Result.error("BSL Language Server exited with status " + exit + " (a clean analyze run " //$NON-NLS-1$ //$NON-NLS-2$ + + "exits 0 even when it reports diagnostics, so this is an operational failure, not " //$NON-NLS-1$ + + "findings). Engine output: " + tail(captured.toString())); //$NON-NLS-1$ + } Path reportPath = outputDir.resolve(REPORT_FILE); if (!Files.isRegularFile(reportPath)) { - return Result.error("BSL Language Server produced no JSON report (exit " + exit + "). " //$NON-NLS-1$ //$NON-NLS-2$ + return Result.error("BSL Language Server produced no JSON report despite exiting 0. " //$NON-NLS-1$ + "Engine output: " + tail(captured.toString())); //$NON-NLS-1$ } @@ -497,17 +520,65 @@ static String extractVersion(String fileName) } /** - * Compares two dotted version strings (e.g. {@code "0.28.0"}, {@code "1.10.0"}) NUMERICALLY, - * component by component - NOT lexicographically, where {@code "1.9.0"} would wrongly sort - * after {@code "1.10.0"}. A version with fewer components is padded with {@code 0} for the - * comparison (so {@code "1.9"} == {@code "1.9.0"}). A non-numeric component (e.g. a pre-release - * suffix glued onto the last segment, like {@code "0-rc1"}) falls back to a plain string - * comparison for just THAT component - full SemVer pre-release precedence is not implemented, - * this only needs to stay deterministic and not throw on the rare pre-release jar name. + * Compares two dotted version strings (e.g. {@code "0.28.0"}, {@code "1.10.0"}, + * {@code "1.10.0-rc1"}) by SemVer PRECEDENCE, not lexicographically (where {@code "1.9.0"} + * would wrongly sort after {@code "1.10.0"}) and not by treating a pre-release suffix as + * just another string tail (where {@code "1.10.0-rc1"} would wrongly sort AFTER + * {@code "1.10.0"} - a stable release must always outrank a pre-release of the same core + * version). + *

+ * The core {@code MAJOR.MINOR.PATCH} is compared numerically, component by component; a + * version with fewer components is padded with {@code 0} (so {@code "1.9"} == {@code "1.9.0"}). + * When the core versions are equal: a release with NO pre-release suffix outranks one that + * has any suffix; two pre-release suffixes are compared per the SemVer identifier rules - + * split on {@code '.'}, each identifier pair compared numerically when BOTH are all-digits, + * lexically otherwise (a numeric identifier always has LOWER precedence than a non-numeric + * one at the same position), and a longer identifier list outranks a shorter one whose + * leading identifiers all matched. A component that still cannot be parsed (an unexpected + * jar-name shape) falls back to a plain string comparison for just that component, so this + * stays deterministic and never throws. * * @return negative/zero/positive as {@code a} is older/equal/newer than {@code b} */ static int compareVersions(String a, String b) + { + int coreCmp = compareCoreVersions(coreVersion(a), coreVersion(b)); + if (coreCmp != 0) + { + return coreCmp; + } + String preA = preReleaseSuffix(a); + String preB = preReleaseSuffix(b); + if (preA == null && preB == null) + { + return 0; + } + if (preA == null) + { + return 1; // a has no pre-release suffix, b does - a outranks b + } + if (preB == null) + { + return -1; + } + return comparePreRelease(preA, preB); + } + + /** @return the part of {@code version} before its first {@code '-'} (or the whole string) */ + private static String coreVersion(String version) + { + int dash = version.indexOf('-'); + return dash < 0 ? version : version.substring(0, dash); + } + + /** @return the part of {@code version} after its first {@code '-'}, or {@code null} when there is none */ + private static String preReleaseSuffix(String version) + { + int dash = version.indexOf('-'); + return dash < 0 ? null : version.substring(dash + 1); + } + + private static int compareCoreVersions(String a, String b) { String[] pa = a.split("\\."); //$NON-NLS-1$ String[] pb = b.split("\\."); //$NON-NLS-1$ @@ -537,6 +608,69 @@ private static int compareVersionComponent(String sa, String sb) } } + /** + * Compares two SemVer pre-release strings (the part after the version's first {@code '-'}, + * e.g. {@code "rc1"} or {@code "rc.2"}) per the SemVer precedence rules for dot-separated + * identifiers. + */ + private static int comparePreRelease(String preA, String preB) + { + String[] idsA = preA.split("\\."); //$NON-NLS-1$ + String[] idsB = preB.split("\\."); //$NON-NLS-1$ + int n = Math.min(idsA.length, idsB.length); + for (int i = 0; i < n; i++) + { + int cmp = comparePreReleaseIdentifier(idsA[i], idsB[i]); + if (cmp != 0) + { + return cmp; + } + } + // All shared identifiers matched: the longer list has higher precedence (SemVer 11.4.4). + return Integer.compare(idsA.length, idsB.length); + } + + private static int comparePreReleaseIdentifier(String idA, String idB) + { + boolean numA = isNumericIdentifier(idA); + boolean numB = isNumericIdentifier(idB); + if (numA && numB) + { + try + { + return Long.compare(Long.parseLong(idA), Long.parseLong(idB)); + } + catch (NumberFormatException e) + { + // Pathologically long digit string - fall through to a plain string comparison + // rather than throw; still deterministic. + } + } + else if (numA != numB) + { + // SemVer 11.4.3: a numeric identifier always has LOWER precedence than a non-numeric + // one compared at the same position. + return numA ? -1 : 1; + } + return idA.compareTo(idB); + } + + private static boolean isNumericIdentifier(String s) + { + if (s.isEmpty()) + { + return false; + } + for (int i = 0; i < s.length(); i++) + { + if (!Character.isDigit(s.charAt(i))) + { + return false; + } + } + return true; + } + /** * Resolves the Java launcher: explicit override, then {@link #ENV_JAVA}, then the * JRE running EDT ({@code java.home}). Returns {@code null} only if none resolves to diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/preferences/ToolGroupTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/preferences/ToolGroupTest.java index 67e037d6b..fd86d65e0 100644 --- a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/preferences/ToolGroupTest.java +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/preferences/ToolGroupTest.java @@ -120,6 +120,9 @@ public void testGetGroupForToolFound() { assertEquals(ToolGroup.CORE, ToolGroup.getGroupForTool("get_edt_version")); assertEquals(ToolGroup.PROBLEMS, ToolGroup.getGroupForTool("get_project_errors")); + assertEquals("code_review is an external-process tool the Tools tab / Disable All must be able " + + "to manage, so it must be reachable through ToolGroup like every other registered tool", + ToolGroup.PROBLEMS, ToolGroup.getGroupForTool("code_review")); assertEquals(ToolGroup.APPLICATIONS, ToolGroup.getGroupForTool("list_configurations")); assertEquals(ToolGroup.DEBUG, ToolGroup.getGroupForTool("set_breakpoint")); assertEquals(ToolGroup.BSL_CODE, ToolGroup.getGroupForTool("read_module_source")); diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java index b9cf4811e..4cfa3941a 100644 --- a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java @@ -34,7 +34,14 @@ */ public class CodeReviewToolTest { - /** Two findings (MagicNumber = Information, UnusedLocalVariable = Warning) + one clean file. */ + /** + * Two findings together, DELIBERATELY of different overlap classes: {@code MagicNumber} + * (Information) has no EDT v8-code-style equivalent - a genuinely additional finding - + * while {@code UnusedLocalVariable} (Warning) overlaps EDT's own unused-code checks + * (get_project_errors). Exercised together so the rule/excludeRule filters below are + * proven against a MIXED set, not just a single-rule report - the mechanism a caller + * uses to drop the overlapping rule while keeping the additional one. + */ private static final String SAMPLE = "{" + "\"fileinfos\":[" + " {\"path\":\"file:///C:/proj/src/CommonModules/Calc/Module.bsl\",\"mdoRef\":\"CommonModule.Calc\"," diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java index 4902d13ca..edcbd1495 100644 --- a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java @@ -290,14 +290,52 @@ public void testCompareVersionsIdentical() assertEquals(0, BslLsRunner.compareVersions("1.10.0", "1.10.0")); } + @Test + public void testCompareVersionsStableReleaseOutranksPreReleaseOfSameCoreVersion() + { + // The exact bug reported against the first fix: "0-rc1" fell into the string fallback + // and compared GREATER than "0" (longer string, same prefix), so autodetect picked the + // RC over the stable release of the identical version - backwards SemVer precedence. + assertTrue("1.10.0 must outrank 1.10.0-rc1 (a stable release beats any pre-release)", + BslLsRunner.compareVersions("1.10.0", "1.10.0-rc1") > 0); + assertTrue("1.10.0-rc1 must be older than 1.10.0", + BslLsRunner.compareVersions("1.10.0-rc1", "1.10.0") < 0); + } + + @Test + public void testCompareVersionsPreReleaseIdentifiersComparedInOrder() + { + assertTrue("rc1 must be older than rc2 (same core version, later pre-release identifier)", + BslLsRunner.compareVersions("1.10.0-rc1", "1.10.0-rc2") < 0); + assertTrue("rc2 must be newer than rc1", + BslLsRunner.compareVersions("1.10.0-rc2", "1.10.0-rc1") > 0); + } + + @Test + public void testCompareVersionsNumericPreReleaseIdentifiersComparedNumericallyNotLexically() + { + // A dot-separated NUMERIC pre-release identifier must compare numerically ("9" < "10"), + // not lexically (where "...rc.10" would wrongly sort before "...rc.9"). + assertTrue("1.10.0-rc.9 must be older than 1.10.0-rc.10", + BslLsRunner.compareVersions("1.10.0-rc.9", "1.10.0-rc.10") < 0); + } + + @Test + public void testCompareVersionsLongerPreReleaseIdentifierListOutranksSharedPrefix() + { + // SemVer 11.4.4: when every shared leading identifier is equal, the longer list wins. + assertTrue("1.10.0-rc.1.1 must be newer than 1.10.0-rc.1", + BslLsRunner.compareVersions("1.10.0-rc.1.1", "1.10.0-rc.1") > 0); + } + @Test public void testCompareVersionsPreReleaseSuffixDoesNotThrow() { - // Full SemVer pre-release precedence is not implemented (see the method's javadoc) - this - // only needs to stay deterministic and not throw on a non-numeric trailing component. - int result = BslLsRunner.compareVersions("1.10.0-rc1", "1.10.0"); + // Defensive: even a pre-release suffix that is not a plain identifier list must stay + // deterministic rather than throw. + int result = BslLsRunner.compareVersions("1.10.0-!!weird??", "1.10.0-###other"); assertEquals("the same comparison must be stable across repeated calls", - result, BslLsRunner.compareVersions("1.10.0-rc1", "1.10.0")); + result, BslLsRunner.compareVersions("1.10.0-!!weird??", "1.10.0-###other")); } @Test @@ -353,6 +391,23 @@ public void testScanForExecJarReturnsNullWhenDirectoryHasNoMatch() throws IOExce assertNull(BslLsRunner.scanForExecJar(dir)); } + @Test + public void testScanForExecJarPrefersStableReleaseOverPreReleaseOfTheSameVersion() throws IOException + { + // The integration case DitriX's re-check asked for: a stable release and an RC of the + // SAME core version sitting side by side must resolve to the STABLE one, not the RC. + File dir = newFolder("stable-and-rc-engine"); + assertTrue(new File(dir, "bsl-language-server-1.10.0-rc1-exec.jar").createNewFile()); + assertTrue(new File(dir, "bsl-language-server-1.10.0-exec.jar").createNewFile()); + assertTrue(new File(dir, "bsl-language-server-1.9.0-exec.jar").createNewFile()); + + File best = BslLsRunner.scanForExecJar(dir); + + assertNotNull(best); + assertEquals("the stable 1.10.0 release must win over both the 1.10.0-rc1 pre-release " + + "and the older 1.9.0 stable release", "bsl-language-server-1.10.0-exec.jar", best.getName()); + } + // ==================== Managed fixture process: real subprocess plumbing without the real engine ==================== @Test @@ -371,6 +426,36 @@ public void testFixtureRunProducesAParseableReport() throws Exception assertNotNull(result.report()); } + @Test + public void testExecuteSubprocessCwdIsWorkspaceDirNotTheNarrowedSrcDir() throws Exception + { + // Regression for a real bug found via live e2e (not any review comment): the actual BSL + // Language Server derives each finding's reported "path" from the PROCESS CWD, not from + // --srcDir. A single-module review narrows request.srcDir to that module's own containing + // folder; if the subprocess CWD were set to srcDir directly (as it once was), the engine's + // path construction doubles the module's folder segment into a non-existent path that + // CodeReviewTool's exact-match scoping can never find - silently emptying EVERY + // module-scoped review. The fixture reports its own System.getProperty("user.dir") as a + // finding path, so this pins the actual subprocess CWD without depending on the real + // engine's undocumented, version-specific path-construction behaviour. + File fixtureJar = buildFixtureJar(); + Assume.assumeTrue("no system Java compiler available to build the fixture jar - skip", //$NON-NLS-1$ + fixtureJar != null); + File workspaceRoot = newFolder("fixture-workspace-root"); //$NON-NLS-1$ + File srcDir = new File(workspaceRoot, "CommonModules/Calc"); //$NON-NLS-1$ + assertTrue(srcDir.mkdirs()); + Files.write(srcDir.toPath().resolve("FIXTURE_BEHAVIOR.txt"), "report-cwd".getBytes()); //$NON-NLS-1$ //$NON-NLS-2$ + + BslLsRunner.Request request = new BslLsRunner.Request(srcDir).workspaceDir(workspaceRoot) + .jarOverride(fixtureJar).javaOverride(currentJavaExecutable()); + BslLsRunner.Result result = BslLsRunner.run(request); + + assertTrue("fixture run must succeed: " + (result.ok() ? "" : result.errorMessage()), result.ok()); //$NON-NLS-1$ //$NON-NLS-2$ + assertEquals("the subprocess CWD must be the workspace root, not the narrowed srcDir", + workspaceRoot.getCanonicalFile(), + new File(result.report().findings().get(0).path()).getCanonicalFile()); + } + @Test public void testFixtureRunWithHugeStdoutStillSucceedsWithBoundedCapture() throws Exception { @@ -392,6 +477,30 @@ public void testFixtureRunWithHugeStdoutStillSucceedsWithBoundedCapture() throws + (result.ok() ? "" : result.errorMessage()), result.ok()); //$NON-NLS-1$ } + @Test + public void testFixtureRunWithNonZeroExitIsRejectedEvenWithAValidReportPresent() throws Exception + { + // The exact bug reported against the engine wrapper: a process that exits non-zero can + // still have left a well-formed bsl-json.json behind (verified empirically against the + // real engine that a CLEAN run - even one that reports diagnostics - always exits 0, so a + // non-zero exit is genuinely an operational failure, not "found problems"). run() must + // refuse to trust that report rather than parse and report success. + File fixtureJar = buildFixtureJar(); + Assume.assumeTrue("no system Java compiler available to build the fixture jar - skip", //$NON-NLS-1$ + fixtureJar != null); + File srcDir = newFolder("fixture-src-nonzero-exit"); //$NON-NLS-1$ + Files.write(srcDir.toPath().resolve("FIXTURE_BEHAVIOR.txt"), "nonzero-exit".getBytes()); //$NON-NLS-1$ //$NON-NLS-2$ + + BslLsRunner.Request request = new BslLsRunner.Request(srcDir) + .jarOverride(fixtureJar).javaOverride(currentJavaExecutable()).timeoutSeconds(60); + BslLsRunner.Result result = BslLsRunner.run(request); + + assertFalse("a non-zero exit must be rejected even though a report file was written", result.ok()); //$NON-NLS-1$ + assertNotNull(result.errorMessage()); + assertTrue("the error must name the actual exit status: " + result.errorMessage(), //$NON-NLS-1$ + result.errorMessage().contains("status 7")); //$NON-NLS-1$ + } + @Test public void testFixtureRunWithOversizedReportFailsLoudWithoutReadingIt() throws Exception { @@ -427,8 +536,12 @@ private static File currentJavaExecutable() * [--configuration ]}, exactly {@link BslLsRunner#buildCommand}'s shape): it locates * {@code --srcDir}/{@code --outputDir} among its own args, reads an optional * {@code FIXTURE_BEHAVIOR.txt} sentinel file from {@code --srcDir} (defaulting to - * {@code "normal"} when absent) and either writes a small valid {@code bsl-json.json}, - * floods stdout, or writes an oversized report, before exiting 0. Compiled in-process via + * {@code "normal"} when absent) and either writes a small valid {@code bsl-json.json} then + * exits 0, floods stdout then exits 0, writes an oversized report then exits 0, writes a + * valid report then exits 7 ({@code "nonzero-exit"} - simulates an operational failure that + * still leaves a report file behind), or reports its own {@code user.dir} system property as + * a finding's path ({@code "report-cwd"} - pins the actual subprocess working directory). + * Compiled in-process via * {@link ToolProvider#getSystemJavaCompiler()} (needs a JDK, not a bare JRE, running the test) so * no extra Maven module/dependency is needed just for this fixture. * @@ -468,6 +581,12 @@ private File buildFixtureJar() throws IOException + " for (int i = 0; i < 60; i++) out.write(chunk);\n" //$NON-NLS-1$ + " out.write(\"\\\"}]}\".getBytes());\n" //$NON-NLS-1$ + " out.close();\n" //$NON-NLS-1$ + + " } else if (\"nonzero-exit\".equals(behavior)) {\n" //$NON-NLS-1$ + + " java.nio.file.Files.write(report.toPath(), \"{\\\"fileinfos\\\":[]}\".getBytes());\n" //$NON-NLS-1$ + + " System.exit(7);\n" //$NON-NLS-1$ + + " } else if (\"report-cwd\".equals(behavior)) {\n" //$NON-NLS-1$ + + " String cwdUri = new java.io.File(System.getProperty(\"user.dir\")).toURI().toString();\n" //$NON-NLS-1$ + + " java.nio.file.Files.write(report.toPath(), (\"{\\\"fileinfos\\\":[{\\\"path\\\":\\\"\" + cwdUri + \"\\\",\\\"diagnostics\\\":[{}]}]}\").getBytes());\n" //$NON-NLS-1$ + " } else {\n" //$NON-NLS-1$ + " java.nio.file.Files.write(report.toPath(), \"{\\\"fileinfos\\\":[]}\".getBytes());\n" //$NON-NLS-1$ + " }\n" //$NON-NLS-1$ diff --git a/tests/e2e/tools_list.golden.json b/tests/e2e/tools_list.golden.json index 605f97535..09213445e 100644 --- a/tests/e2e/tools_list.golden.json +++ b/tests/e2e/tools_list.golden.json @@ -188,6 +188,55 @@ "type": "object" } }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Review BSL code quality with the BSL Language Server engine: its FULL diagnostic catalog (magic number, cyclomatic/cognitive complexity, method/line length, nesting, naming, unused code, …) — this overlaps with EDT's own v8-code-style checks (get_project_errors), it is not a strict delta over them; use excludeRule to drop rule ids you already get elsewhere. Each finding is a defect to FIX: it carries the rule, severity, Module path and Line, ready for read_module_source / write_module_source — fix each, then re-run code_review to verify. Scope the whole project or one module; filter by severity, rule or excludeRule. Needs the engine jar (see the guide). Full parameters and examples: call get_tool_guide('code_review').", + "inputSchema": { + "properties": { + "excludeRule": { + "description": "Optional: drop diagnostics whose rule id contains this substring — e.g. to exclude rules you already get from get_project_errors and avoid double-reporting the same issue.", + "type": "string" + }, + "limit": { + "description": "Max findings; default 100, max 1000 (optional).", + "type": "integer" + }, + "modulePath": { + "description": "Optional: narrow the review to a single module, path from src/ (e.g. 'CommonModules/Calc/Module.bsl'). Omit to review the whole configuration.", + "type": "string" + }, + "projectName": { + "description": "EDT project name to review.", + "type": "string" + }, + "rule": { + "description": "Optional: report only diagnostics whose rule id contains this substring (e.g. 'Magic', 'Complexity').", + "type": "string" + }, + "severity": { + "description": "Optional: minimum severity to report (error > warning > information > hint). Omit to report all.", + "enum": [ + "error", + "warning", + "information", + "hint" + ], + "type": "string" + } + }, + "required": [ + "projectName" + ], + "type": "object" + }, + "name": "code_review", + "outputSchema": null + }, { "annotations": { "destructiveHint": false, From b4c433bb59d721cfc39cbb79eb4fe00a988c8c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B0=D1=80=D0=B0=D1=81=D0=BE=D0=B2=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Mon, 10 Aug 2026 09:08:41 +0300 Subject: [PATCH 5/8] =?UTF-8?q?code=5Freview:=20=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=B8=D1=82=D1=8C=20=D1=87=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=B2=D0=B8=D0=B6=D0=BA=D0=B0=20=D1=87=D0=B0=D0=BD=D0=BA=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D1=87=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замечание codex: drainAsync читал вывод подпроцесса через readLine(), то есть материализовал строку целиком ДО того, как срабатывал MAX_CAPTURED_OUTPUT_CHARS. Одна огромная строка без перевода (краш-дамп, обёртка, печатающая файл целиком, движок в цикле без разделителя) съедала кучу EDT независимо от того, насколько мал предел. Javadoc при этом обещал ограниченность — то есть обещал больше, чем код давал. Пункт №3 предпусковой проверки CLAUDE.md ровно про это. Теперь чтение идёт фиксированным буфером char[DRAIN_CHUNK_CHARS]: транзиентно удерживается не больше 8 КБ, где бы ни стояли (и стоят ли вообще) переводы строк. Тест: существующая фикстура huge-stdout печатала 5000 МАЛЕНЬКИХ строк, поэтому readLine никогда не держал больше 2000 символов и дыру не задевала. Добавлена фикстура huge-single-line — ~20 МБ одной строкой через print() без println(). В комментарии к тесту честно оговорено, что он проверяет поведение (поток дочитывается, ребёнок завершается, отчёт разбирается), а сам предел на аллокацию структурный — фиксированный буфер. Второе замечание (--configuration якобы игнорируется как «глобальный», из-за чего проектный .bsl-language-server.json не применяется) ОТКЛОНЕНО — проверено на реальном движке 1.0.3: - прогон без конфига: EmptyCodeBlock + UnusedLocalVariable; - тот же прогон с --configuration на проектный файл, отключающий EmptyCodeBlock: остаётся только UnusedLocalVariable, то есть конфиг применён; - встречная проверка: конфиг, положенный в workspace-корень без --configuration, тоже подхватывается. Движок honours оба способа, наш путь корректен. Также влит origin/master (8 коммитов). Пересечение со стороной ветки только в tools_list.golden.json; проверено, что смёрженный golden совпадает с живым сервером, регистрация code_review уцелела во всех трёх точках. Проверено: сборка 4546 тестов зелёная, живой редеплой (jar проверен на antistale), e2e code_review 8/8, golden сходится, docs без изменений, fixture clean: True. --- .../edt/mcp/server/utils/BslLsRunner.java | 36 +++++++++++++------ .../edt/mcp/server/utils/BslLsRunnerTest.java | 35 ++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java index e24bb821d..5edaa953c 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java @@ -6,10 +6,10 @@ package com.ditrix.edt.mcp.server.utils; -import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; +import java.io.Reader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -66,6 +66,13 @@ public final class BslLsRunner */ static final int MAX_CAPTURED_OUTPUT_CHARS = 8_000; + /** + * Transient read-buffer size for {@link #drainAsync}. This is what makes + * {@link #MAX_CAPTURED_OUTPUT_CHARS} an honest bound: the drain never holds more than this at + * once, no matter how far apart (or absent) the child's newlines are. + */ + private static final int DRAIN_CHUNK_CHARS = 8_192; + /** * Bound on the engine's JSON report file size, checked BEFORE it is read into memory. A report * this large indicates a pathological/misconfigured run (or a corrupt engine process) — reading @@ -739,23 +746,32 @@ private static String javaNotFoundMessage() /** * Drains the process's merged stdout+stderr on a background thread so the child never blocks * on a full OS pipe buffer, while keeping {@code sink}'s RETAINED size bounded to - * {@link #MAX_CAPTURED_OUTPUT_CHARS}: the stream is read in full regardless (every line is - * consumed), but once the buffer exceeds the cap its FRONT is trimmed, so a subprocess that - * produces gigabytes of chatter (or loops printing) cannot grow this buffer without bound — only - * the tail is ever shown to a caller anyway (see {@link #tail}). + * {@link #MAX_CAPTURED_OUTPUT_CHARS}: the stream is read in full regardless, but once the + * buffer exceeds the cap its FRONT is trimmed, so a subprocess that produces gigabytes of + * chatter (or loops printing) cannot grow this buffer without bound — only the tail is ever + * shown to a caller anyway (see {@link #tail}). + *

+ * Read in FIXED-SIZE chunks rather than with {@code readLine()}. The cap above bounds what is + * RETAINED; {@code readLine} would defeat it before it is ever consulted, because it must + * materialize a whole line first. A child emitting one enormous line with NO newline — a crash + * dump, a wrapper echoing a file, an engine looping without ever writing a line separator — + * would then allocate that entire line on the EDT heap however small the cap is. Chunked + * reading makes the declared bound real: at most {@link #DRAIN_CHUNK_CHARS} are held + * transiently, wherever (or whether) newlines fall. CLAUDE.md pre-push item #3 — bound what is + * read from an external process BEFORE materializing it. */ private static Thread drainAsync(Process process, StringBuilder sink) { Thread t = new Thread(() -> { - try (BufferedReader reader = - new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) + try (Reader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) { - String line; - while ((line = reader.readLine()) != null) + char[] chunk = new char[DRAIN_CHUNK_CHARS]; + int read; + while ((read = reader.read(chunk)) != -1) { synchronized (sink) { - sink.append(line).append('\n'); + sink.append(chunk, 0, read); if (sink.length() > MAX_CAPTURED_OUTPUT_CHARS) { sink.delete(0, sink.length() - MAX_CAPTURED_OUTPUT_CHARS); diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java index edcbd1495..75077a09b 100644 --- a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java @@ -477,6 +477,35 @@ public void testFixtureRunWithHugeStdoutStillSucceedsWithBoundedCapture() throws + (result.ok() ? "" : result.errorMessage()), result.ok()); //$NON-NLS-1$ } + @Test + public void testFixtureRunWithOneHugeUnterminatedLineStillCompletes() throws Exception + { + // The gap the line-oriented drain left: testFixtureRunWithHugeStdoutStillSucceedsWithBoundedCapture + // above emits 5000 SMALL lines, so readLine() never held more than 2000 chars and the case + // never exercised what happens with no line separator at all. This fixture prints ~20 MB as + // ONE unterminated line (System.out.print, never println), which is what a crash dump or a + // wrapper echoing a file looks like. + // + // Honest about what this proves: it is a BEHAVIOURAL guard - the drain must still consume + // such a stream fully, let the child exit, and leave the report parseable. It does NOT by + // itself measure the transient allocation (a readLine-based drain would also have finished + // here, just after materializing the whole 20 MB first). The allocation bound is structural + // - drainAsync reads into a fixed char[DRAIN_CHUNK_CHARS] - and this test is what fails if + // anyone reverts to a line-oriented read that also mishandles the unterminated tail. + File fixtureJar = buildFixtureJar(); + Assume.assumeTrue("no system Java compiler available to build the fixture jar - skip", //$NON-NLS-1$ + fixtureJar != null); + File srcDir = newFolder("fixture-src-huge-single-line"); //$NON-NLS-1$ + Files.write(srcDir.toPath().resolve("FIXTURE_BEHAVIOR.txt"), "huge-single-line".getBytes()); //$NON-NLS-1$ //$NON-NLS-2$ + + BslLsRunner.Request request = new BslLsRunner.Request(srcDir) + .jarOverride(fixtureJar).javaOverride(currentJavaExecutable()).timeoutSeconds(60); + BslLsRunner.Result result = BslLsRunner.run(request); + + assertTrue("a child emitting one unterminated multi-MB line must still complete and parse: " //$NON-NLS-1$ + + (result.ok() ? "" : result.errorMessage()), result.ok()); //$NON-NLS-1$ + } + @Test public void testFixtureRunWithNonZeroExitIsRejectedEvenWithAValidReportPresent() throws Exception { @@ -573,6 +602,12 @@ private File buildFixtureJar() throws IOException + " for (int i = 0; i < 2000; i++) line.append('x');\n" //$NON-NLS-1$ + " for (int i = 0; i < 5000; i++) System.out.println(line);\n" //$NON-NLS-1$ + " java.nio.file.Files.write(report.toPath(), \"{\\\"fileinfos\\\":[]}\".getBytes());\n" //$NON-NLS-1$ + + " } else if (\"huge-single-line\".equals(behavior)) {\n" //$NON-NLS-1$ + + " StringBuilder chunk = new StringBuilder();\n" //$NON-NLS-1$ + + " for (int i = 0; i < 100000; i++) chunk.append('y');\n" //$NON-NLS-1$ + + " for (int i = 0; i < 200; i++) System.out.print(chunk);\n" //$NON-NLS-1$ + + " System.out.flush();\n" //$NON-NLS-1$ + + " java.nio.file.Files.write(report.toPath(), \"{\\\"fileinfos\\\":[]}\".getBytes());\n" //$NON-NLS-1$ + " } else if (\"huge-report\".equals(behavior)) {\n" //$NON-NLS-1$ + " java.io.FileOutputStream out = new java.io.FileOutputStream(report);\n" //$NON-NLS-1$ + " out.write(\"{\\\"fileinfos\\\":[{\\\"path\\\":\\\"x\\\",\\\"diagnostics\\\":[],\\\"pad\\\":\\\"\".getBytes());\n" //$NON-NLS-1$ From 8cb8c2c441f5e95ca869b942290f6b1e4c6e77bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B0=D1=80=D0=B0=D1=81=D0=BE=D0=B2=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 18 Aug 2026 09:00:11 +0300 Subject: [PATCH 6/8] =?UTF-8?q?code=5Freview:=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8B=20=D0=B2=D1=81=D0=B5=20=D0=BD=D0=B0=D0=BA?= =?UTF-8?q?=D0=BE=D0=BF=D0=B8=D0=B2=D1=88=D0=B8=D0=B5=D1=81=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=20+=20=D1=81=D0=B0=D0=BC=D0=BE=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мерж origin/master (30 коммитов). Конфликты: два списка регистрации (обе стороны сохранены — master перевёл регистратор на catalogue.add, наш тул добавлен в новом стиле) и два генерируемых индекса (перегенерированы на живом стенде). Замечания codex от 10 и 12 августа: - Files.walk без try-with-resources — утечка файловых дескрипторов в долгоживущем процессе EDT: файлы удалялись, дескрипторы нет - отчёт без массива fileinfos превращался в «проблем не найдено», хотя движок мог вернуть {} — движок не той версии или обёртка маскировались под чистый проект - modulePath принимал любой существующий файл: Configuration.mdo давал ответ «модуль чист» для файла, который движок в принципе не проверяет - совет «поднимите таймаут» при отсутствии такого параметра - выбор jar не учитывал Java: 1.x требует Java 21, и на Java 17 запуск падал с UnsupportedClassVersionError, хотя совместимый движок лежал рядом - traceLog в проектном конфиге заставлял read-only тул писать файл в проект Про traceLog: проверено на живом движке 1.0.3 — с "traceLog" в конфиге прогон действительно создаёт файл в корне проекта. Теперь пишущие ключи вырезаются из конфига, который отдаётся движку; остальные настройки идут как есть. Закрыт и путь, где конфиг вообще не передаётся: движок ищет его сам в рабочем каталоге (это src/ проекта) и в домашнем — оба теперь проходят ту же очистку. Про выбор движка: если рядом в той же папке лежит совместимый jar, он используется вместо отказа — иначе совет «поставьте 0.28.x» получал тот, кто её уже поставил. Найдено самопроверкой /code-review и починено здесь же: - captured читался без того замка, под которым его пишет поток-дренаж - stdin ребёнка не закрывался: обёртка, читающая stdin, висела до таймаута - rule:"" молча отбрасывал находки без кода вместо «фильтра нет» (соседние excludeRule/severityMin так не делают) - парсер отчёта потянул зависимость на раннер ради имени переменной окружения, нарушив собственный инвариант «без process/IO» - deleteQuietly дублировал FileUtil.deleteRecursivelyWithRetries, который в проекте уже используется ровно для этой windows-проблемы с удержанным хендлом - toLowerCase без Locale в isWindows — единственный оставшийся в фиче - ключ сортировки пересчитывался на каждое сравнение вместо одного раза на находку - проект проверялся через exists() вместо isOpen(): закрытый проект анализировался как открытый - escapeForTable применялся к заголовку H1, где даёт видимый backslash - ДВА висячих javadoc, оба созданы моими же вставками (ратчет на это в проекте есть, но он не гейт, поэтому сборка молчала) - таймаут 180с был выше потолка транспорта: наша осмысленная ошибка не успевала дойти, клиент рвал вызов раньше. Снижен до 45с — та же величина и по той же причине, что в RunYaxunitTestsTool Тесты: 5344 юнита (+11), e2e code_review 10/10 — добавлены прогон excludeRule (единственный параметр без живого покрытия) и отказ по не-BSL пути. Первая версия теста на excludeRule упала: искала подстроку по всему ответу и цеплялась за легенду под таблицей, где правило названо как пример — переписана на разбор ячеек. Гайд и схема приведены в соответствие с кодом: .bsl-ограничение, порядок поиска конфига, вырезание traceLog, предел 45с и честная оговорка, что сужение по modulePath лишает движок межмодульного контекста. Проверено: сборка зелёная, живой редеплой на тестовый стенд (jar проверен на antistale), golden перегенерирован, e2e code_review 10/10, get_project_errors 21/21, фикстура чистая. --- README.md | 179 ++++----- docs/tools/README.md | 179 ++++----- docs/tools/code_review.md | 11 +- .../guides/code_review.md | 9 +- .../mcp/server/tools/impl/CodeReviewTool.java | 63 ++- .../edt/mcp/server/utils/BslLsReport.java | 19 +- .../edt/mcp/server/utils/BslLsRunner.java | 358 ++++++++++++++++-- .../server/tools/impl/CodeReviewToolTest.java | 26 ++ .../edt/mcp/server/utils/BslLsReportTest.java | 34 +- .../edt/mcp/server/utils/BslLsRunnerTest.java | 96 ++++- tests/e2e/tools/test_code_review.py | 58 +++ tests/e2e/tools_list.golden.json | 57 ++- 12 files changed, 862 insertions(+), 227 deletions(-) diff --git a/README.md b/README.md index a71ca9e46..606f3d21e 100644 --- a/README.md +++ b/README.md @@ -469,7 +469,7 @@ with `python docs/generate_tool_docs.py`. -**89 tools**, grouped by toolset. Full per-tool pages under [docs/tools/](docs/tools/). +**90 tools**, grouped by toolset. Full per-tool pages under [docs/tools/](docs/tools/). ### Core @@ -477,18 +477,18 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`enable_toolset`](docs/tools/enable_toolset.md) | Reveal (or hide) tool groups for progressive disclosure. Pass toolsets=[ids] from list_toolsets to reveal them, then RE-REQUEST tools/list to see the newly r… | -| [`get_edt_version`](docs/tools/get_edt_version.md) | Returns the running 1C:EDT version as a plain version string. Returns "Unknown" when the version cannot be determined. | -| [`get_metadata_details`](docs/tools/get_metadata_details.md) | Get detailed properties of one or more 1C metadata objects (basic info by default, or every reflected section with 'full: true'). Use it after get_metadata_o… | -| [`get_metadata_objects`](docs/tools/get_metadata_objects.md) | Get a flat list of 1C configuration metadata objects (Name, Synonym, Comment, Type, ObjectModule, ManagerModule) as a Markdown table. Use it to discover what… | -| [`get_module_structure`](docs/tools/get_module_structure.md) | Get structure of a BSL module: all procedures/functions with signatures, line numbers, regions, execution context (&AtServer, &AtClient), export flag, and pa… | -| [`get_server_status`](docs/tools/get_server_status.md) | Self-diagnosis snapshot of the running MCP server: listening port, MCP protocol version, plugin version, EDT version, enabled/total tool counts, the plainTex… | -| [`get_tool_guide`](docs/tools/get_tool_guide.md) | Get the full on-demand how-to for a tool: its description, every parameter (type, required, allowed values) and extended examples/preconditions kept OUT of t… | -| [`list_modules`](docs/tools/list_modules.md) | List BSL modules in an EDT project as a table (module path, module type, parent type, parent name). Use it to discover module paths before reading or editing… | -| [`list_projects`](docs/tools/list_projects.md) | List all workspace projects with properties (name, path, type, natures). format='md' (default) returns the human Markdown table; format='json' returns the ma… | -| [`list_toolsets`](docs/tools/list_toolsets.md) | List the tool groups (toolsets) used by progressive tool disclosure: each toolset's id, title, description, member tools, and whether it is currently visible… | -| [`read_module_source`](docs/tools/read_module_source.md) | Read BSL module source code from an EDT project, whole file or a line range. Returns YAML frontmatter (including a contentHash revision token to round-trip i… | -| [`search_in_code`](docs/tools/search_in_code.md) | Literal/regex full-text search across all BSL modules in a project. Matching is purely textual and NOT ru/en dialect-aware, so a query in one BSL language wo… | +| [`enable_toolset`](docs/tools/enable_toolset.md) | Make additional MCP tool groups visible or hide them. Parameters and examples: get_tool_guide('enable_toolset'). | +| [`get_edt_version`](docs/tools/get_edt_version.md) | Identify the installed 1C:EDT version. Parameters and examples: get_tool_guide('get_edt_version'). | +| [`get_metadata_details`](docs/tools/get_metadata_details.md) | Inspect the properties and structure of a metadata object or member. Parameters and examples: get_tool_guide('get_metadata_details'). | +| [`get_metadata_objects`](docs/tools/get_metadata_objects.md) | Discover metadata objects available in a 1C configuration. Parameters and examples: get_tool_guide('get_metadata_objects'). | +| [`get_module_structure`](docs/tools/get_module_structure.md) | Discover procedures, functions, regions, and execution contexts in a BSL module. Parameters and examples: get_tool_guide('get_module_structure'). | +| [`get_server_status`](docs/tools/get_server_status.md) | Diagnose the EDT MCP server and its feature configuration. Parameters and examples: get_tool_guide('get_server_status'). | +| [`get_tool_guide`](docs/tools/get_tool_guide.md) | Retrieve detailed instructions for an MCP tool. Parameters and examples: get_tool_guide('get_tool_guide'). | +| [`list_modules`](docs/tools/list_modules.md) | Discover BSL modules available in an EDT project. Parameters and examples: get_tool_guide('list_modules'). | +| [`list_projects`](docs/tools/list_projects.md) | Discover projects available in the EDT workspace. Parameters and examples: get_tool_guide('list_projects'). | +| [`list_toolsets`](docs/tools/list_toolsets.md) | Discover available groups of MCP tools and their visibility. Parameters and examples: get_tool_guide('list_toolsets'). | +| [`read_module_source`](docs/tools/read_module_source.md) | Inspect the source of a complete BSL module. Parameters and examples: get_tool_guide('read_module_source'). | +| [`search_in_code`](docs/tools/search_in_code.md) | Literal/regex full-text search across BSL modules. Matching is textual and NOT ru/en dialect-aware, so a query in one BSL language will not find the other sp… | ### Metadata @@ -496,19 +496,19 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`adopt_metadata_object`](docs/tools/adopt_metadata_object.md) | Adopt a base-configuration metadata object or member (object / form / attribute / tabular section / ...) into a configuration EXTENSION so the extension can… | -| [`create_launch_config`](docs/tools/create_launch_config.md) | Create a 1C:EDT runtime-client launch configuration (thin/thick/web). The SAME config works for both run and debug (mode is chosen at launch time by debug_la… | -| [`create_metadata`](docs/tools/create_metadata.md) | Create a metadata node addressed by a 1C full-name FQN: a top-level object (Catalog.Products) or a subordinate member (Catalog.Products.Attribute.Weight, Inf… | -| [`delete_launch_config`](docs/tools/delete_launch_config.md) | Delete a 1C:EDT launch configuration by name (runtime client or Attach). Destructive: guarded by a confirm-preview - call without confirm to preview (no chan… | -| [`delete_metadata`](docs/tools/delete_metadata.md) | Delete a metadata node addressed by a 1C full-name FQN - a top object, an mdclass MEMBER (attribute / tabular section / dimension / resource / enum value), a… | -| [`export_common_picture`](docs/tools/export_common_picture.md) | Export a 1C CommonPicture (общая картинка) as PNG and list its picture variants (dpi, theme, interface variant, direction, template flag, glyph size). Resolv… | -| [`get_configuration_properties`](docs/tools/get_configuration_properties.md) | Get 1C:Enterprise configuration properties (name, synonym, comment, script variant, compatibility mode, etc.) | -| [`get_subsystem_content`](docs/tools/get_subsystem_content.md) | Get one 1C subsystem's content: properties, its metadata objects (Type/Name/Synonym/FQN) and child subsystems, identified by FQN (e.g. 'Subsystem.Sales.Subsy… | -| [`list_common_pictures`](docs/tools/list_common_pictures.md) | List a 1C configuration's CommonPicture objects and the variants each carries in its Picture.zip (DPI, theme, interface variant, template flag, glyph size, p… | -| [`list_configurations`](docs/tools/list_configurations.md) | List EDT launch configurations (runtime client + Attach + other 1C types) with their running state. This is the discovery step before debug_launch / run_yaxu… | -| [`list_subsystems`](docs/tools/list_subsystems.md) | List 1C subsystems of a configuration as a flat table (FQN, Synonym, Comment, InCommandInterface, content count, children count). Walks the whole tree by def… | -| [`modify_metadata`](docs/tools/modify_metadata.md) | Set properties of a metadata node - an object, a member, or a FORM member (item / attribute / command / handler) - addressed by a 1C full-name FQN, as proper… | -| [`rename_metadata_object`](docs/tools/rename_metadata_object.md) | Rename a metadata object, one of its members, or a managed-form element (attribute / command / field / button / group / decoration / table / attribute column… | +| [`adopt_metadata_object`](docs/tools/adopt_metadata_object.md) | Add a base-configuration object or member to an extension for customization. Parameters and examples: get_tool_guide('adopt_metadata_object'). | +| [`create_launch_config`](docs/tools/create_launch_config.md) | Configure an EDT runtime client for launching a 1C application. Parameters and examples: get_tool_guide('create_launch_config'). | +| [`create_metadata`](docs/tools/create_metadata.md) | Add a new metadata object or member to a configuration. Parameters and examples: get_tool_guide('create_metadata'). | +| [`delete_launch_config`](docs/tools/delete_launch_config.md) | Remove an unused EDT runtime or attach launch configuration. Two-phase: call once WITHOUT confirm to preview, then again with confirm=true to apply. Paramete… | +| [`delete_metadata`](docs/tools/delete_metadata.md) | Delete a metadata object or member (FQN-addressed). DESTRUCTIVE and CASCADING: on the md-refactoring path EDT cleans the REFERENCES to the deleted object acr… | +| [`export_common_picture`](docs/tools/export_common_picture.md) | Inspect or extract the image data of a 1C common picture. Parameters and examples: get_tool_guide('export_common_picture'). | +| [`get_configuration_properties`](docs/tools/get_configuration_properties.md) | Inspect the identity and compatibility settings of a 1C configuration. Parameters and examples: get_tool_guide('get_configuration_properties'). | +| [`get_subsystem_content`](docs/tools/get_subsystem_content.md) | Inspect which metadata objects and child subsystems belong to a 1C subsystem. Parameters and examples: get_tool_guide('get_subsystem_content'). | +| [`list_common_pictures`](docs/tools/list_common_pictures.md) | Inventory common pictures available in a 1C configuration. Parameters and examples: get_tool_guide('list_common_pictures'). | +| [`list_configurations`](docs/tools/list_configurations.md) | Discover EDT runtime and server-side launch configurations. Parameters and examples: get_tool_guide('list_configurations'). | +| [`list_subsystems`](docs/tools/list_subsystems.md) | Discover the subsystem hierarchy of a 1C configuration. Parameters and examples: get_tool_guide('list_subsystems'). | +| [`modify_metadata`](docs/tools/modify_metadata.md) | Set properties of any metadata node (object or member, including form items, attributes, commands, and handlers). Parameters and examples: get_tool_guide('mo… | +| [`rename_metadata_object`](docs/tools/rename_metadata_object.md) | Rename a metadata object or member and rewrite the references EDT RESOLVES for it. CASCADES ACROSS THE WHOLE CONFIGURATION - BSL, forms, roles, subsystems -… | ### Code @@ -516,15 +516,15 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`find_references`](docs/tools/find_references.md) | Find every place a metadata object is used: BSL code modules (with line numbers), other metadata, forms, roles, subsystems, etc. Pass the object FQN; the typ… | -| [`get_content_assist`](docs/tools/get_content_assist.md) | Get code-completion proposals at a 1-based line/column in a BSL module - the members, globals and variables valid at that caret (e.g. after a '.'). May retur… | +| [`find_references`](docs/tools/find_references.md) | Discover where a metadata object is used throughout the configuration and BSL code. Parameters and examples: get_tool_guide('find_references'). | +| [`get_content_assist`](docs/tools/get_content_assist.md) | Find valid BSL completion suggestions at a source-code position. Parameters and examples: get_tool_guide('get_content_assist'). | | [`get_method_call_hierarchy`](docs/tools/get_method_call_hierarchy.md) | Trace which BSL methods call a method or are called by it; optional depth walks the chain transitively for impact analysis (callers only, max 5). Finds STATI… | -| [`get_outgoing_structures`](docs/tools/get_outgoing_structures.md) | For each outgoing qualified call in a BSL module (or one method), report the top-level literal keys of the Structure passed as its first argument (local .Ins… | -| [`get_symbol_info`](docs/tools/get_symbol_info.md) | Get type/hover info about a symbol at a position in a BSL module. Returns inferred types, signatures, and documentation. | -| [`go_to_definition`](docs/tools/go_to_definition.md) | Go to the definition of a symbol (the inverse of find_references): a qualified method 'ModuleName.MethodName', a bare 'MethodName' (also pass modulePath), or… | -| [`read_method_source`](docs/tools/read_method_source.md) | Read a specific procedure/function from a BSL module by name. Returns source code with metadata. Lists available methods if not found. Use this for one metho… | -| [`validate_query`](docs/tools/validate_query.md) | Validate 1C:Enterprise query language (QL) text against a project, returning syntax and semantic errors with line numbers. Use to check a query before embedd… | -| [`write_module_source`](docs/tools/write_module_source.md) | Write BSL source code to a 1C metadata object module. Use to edit a module: searchReplace a fragment (default, needs oldSource), replace the whole file, or a… | +| [`get_outgoing_structures`](docs/tools/get_outgoing_structures.md) | Discover the fields passed to outgoing or qualified BSL method calls. BEST-EFFORT and incomplete by design: only top-level LITERAL keys of the first argument… | +| [`get_symbol_info`](docs/tools/get_symbol_info.md) | Inspect the type and documentation of a BSL symbol at its source location. Parameters and examples: get_tool_guide('get_symbol_info'). | +| [`go_to_definition`](docs/tools/go_to_definition.md) | Locate the source definition of a BSL symbol or metadata object. Parameters and examples: get_tool_guide('go_to_definition'). | +| [`read_method_source`](docs/tools/read_method_source.md) | Inspect the source of one BSL procedure or function. Parameters and examples: get_tool_guide('read_method_source'). | +| [`validate_query`](docs/tools/validate_query.md) | Check a 1C query for syntax and metadata-reference errors. Parameters and examples: get_tool_guide('validate_query'). | +| [`write_module_source`](docs/tools/write_module_source.md) | Create or edit BSL source in a metadata module. Parameters and examples: get_tool_guide('write_module_source'). | ### Debug @@ -532,19 +532,19 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`debug_launch`](docs/tools/debug_launch.md) | Start an EDT debug session: either an existing config by launchConfigurationName (runtime client OR Attach, the latter needed to debug server-side code), or… | -| [`debug_status`](docs/tools/debug_status.md) | Report active debug sessions: applicationId (real or synthetic 'attach:' / 'launch:'), launch configuration name/type, mode (debug/run), whether… | -| [`evaluate_expression`](docs/tools/evaluate_expression.md) | Evaluate a BSL expression in the context of a suspended stack frame. Pass frameRef from wait_for_break and the expression text. WARNING: this executes arbitr… | -| [`get_applications`](docs/tools/get_applications.md) | Get list of applications (infobases) for a project. Returns application ID, name, type, and update state. Application ID is required for update_database and… | -| [`get_variables`](docs/tools/get_variables.md) | Read variables from a stack frame of a suspended debug thread. Pass frameRef from wait_for_break (preferred) or threadId+frameIndex. Use expandPath to drill… | -| [`list_breakpoints`](docs/tools/list_breakpoints.md) | List active line breakpoints. Optionally filter by projectName. | -| [`remove_breakpoint`](docs/tools/remove_breakpoint.md) | Remove a 1C BSL line breakpoint. Either pass breakpointId (returned from set_breakpoint) or projectName+module+lineNumber to look it up by coordinates. | -| [`resume`](docs/tools/resume.md) | Resume a suspended debug thread or all threads of a debug target. Pass threadId (from wait_for_break) or applicationId. applicationId accepts ANY id form for… | -| [`set_breakpoint`](docs/tools/set_breakpoint.md) | Set a line breakpoint on a 1C BSL module. Accepts either an EDT module-relative path (e.g. 'CommonModules/Foo/Module.bsl') or an absolute filesystem path. Us… | -| [`set_variable`](docs/tools/set_variable.md) | Set a BSL variable's value in a suspended debug frame. WRITE/side-effect: EXECUTES the entered value as a BSL literal/expression live in the running 1C appli… | -| [`step`](docs/tools/step.md) | Step a suspended debug thread. kind ∈ {over, into, out}. Blocks until the next SUSPEND event (or timeout) and returns the new frame snapshot. | -| [`terminate_launch`](docs/tools/terminate_launch.md) | Terminate one or more 1C launches started from THIS EDT instance; externally launched 1C clients are never touched. Select ONE target mode: launchConfigurati… | -| [`wait_for_break`](docs/tools/wait_for_break.md) | Wait for a debug suspend event (e.g. breakpoint hit) on the given application. Returns the suspended thread/frame snapshot, or {hit:false} on timeout. applic… | +| [`debug_launch`](docs/tools/debug_launch.md) | Run a 1C application under EDT debugging. An already-running session is NOT relaunched - the call short-circuits with alreadyRunning:true; restartIfRunning=t… | +| [`debug_status`](docs/tools/debug_status.md) | Check which EDT debug sessions are running or paused. Parameters and examples: get_tool_guide('debug_status'). | +| [`evaluate_expression`](docs/tools/evaluate_expression.md) | Evaluate a BSL expression in a paused debug frame and return the value. WARNING: this executes arbitrary code in the running application - it can change stat… | +| [`get_applications`](docs/tools/get_applications.md) | Discover infobases connected to an EDT project. Parameters and examples: get_tool_guide('get_applications'). | +| [`get_variables`](docs/tools/get_variables.md) | Inspect runtime variables in a paused debug frame. Parameters and examples: get_tool_guide('get_variables'). | +| [`list_breakpoints`](docs/tools/list_breakpoints.md) | Review breakpoints currently set in BSL source code. Parameters and examples: get_tool_guide('list_breakpoints'). | +| [`remove_breakpoint`](docs/tools/remove_breakpoint.md) | Stop pausing execution at a BSL source breakpoint. Address it EITHER by breakpointId OR by modulePath together with lineNumber - every field is optional on i… | +| [`resume`](docs/tools/resume.md) | Continue a paused 1C debug session. Parameters and examples: get_tool_guide('resume'). | +| [`set_breakpoint`](docs/tools/set_breakpoint.md) | Pause BSL execution at a selected source line during debugging. Parameters and examples: get_tool_guide('set_breakpoint'). | +| [`set_variable`](docs/tools/set_variable.md) | Change a variable while execution is paused in the debugger. WARNING: the value is EVALUATED as a BSL expression in the running application, so it can invoke… | +| [`step`](docs/tools/step.md) | Advance paused debug execution one step. Parameters and examples: get_tool_guide('step'). | +| [`terminate_launch`](docs/tools/terminate_launch.md) | Stop 1C sessions started by EDT. NOT two-phase for a single launch: selecting one (launchConfigurationName, or projectName + applicationId) stops it IMMEDIAT… | +| [`wait_for_break`](docs/tools/wait_for_break.md) | Wait until a running 1C debug session reaches a breakpoint or other suspend event. Parameters and examples: get_tool_guide('wait_for_break'). | ### Testing @@ -552,11 +552,11 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`ask_workmate`](docs/tools/ask_workmate.md) | Start a background question to the 1C:Workmate plugin and return its jobId. Poll the job with get_job_status instead of calling ask_workmate again. Requires… *(not enabled by default)* | -| [`cancel_job`](docs/tools/cancel_job.md) | Preview or cancel a background job by jobId. A confirmed job uses its owning tool's declared cancellation capability when one exists; unsupported committed… | -| [`debug_yaxunit_tests`](docs/tools/debug_yaxunit_tests.md) | Deprecated alias for run_yaxunit_tests with debug=true. A short named job returns the launch handle; Pending returns jobId for get_job_status before wait_for… | -| [`get_job_status`](docs/tools/get_job_status.md) | Poll any background job by the jobId returned from its owning tool. Returns the current state, progress journal, and terminal result; optionally waits for a… | -| [`run_yaxunit_tests`](docs/tools/run_yaxunit_tests.md) | Run YAXUnit tests as a named background job and return a JUnit Markdown report. The start call waits up to `timeout` (default and maximum 45s, larger values… | +| [`ask_workmate`](docs/tools/ask_workmate.md) | Start a background question to the 1C:Workmate plugin and return its jobId. Hands the question to an EXTERNAL agent: by default (shareMcpTools) Workmate may… *(not enabled by default)* | +| [`cancel_job`](docs/tools/cancel_job.md) | Cancel a background job by jobId. DESTRUCTIVE. Two-phase: call once WITHOUT confirm to see the owning tool, state and progress, then again with confirm=true… | +| [`debug_yaxunit_tests`](docs/tools/debug_yaxunit_tests.md) | DEPRECATED alias of run_yaxunit_tests(debug=true) - prefer that instead; the implementation is shared. DEBUG mode, so breakpoints fire: a short start returns… | +| [`get_job_status`](docs/tools/get_job_status.md) | Poll any background job by the jobId its owning tool returned: state, progress journal and terminal result. Parameters and examples: get_tool_guide('get_job_… | +| [`run_yaxunit_tests`](docs/tools/run_yaxunit_tests.md) | Run YAXUnit tests as a named background job and return a JUnit Markdown report. The start call waits up to `timeout` (default and maximum 45s): a short run r… | ### Profiling @@ -564,9 +564,9 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`get_profiling_results`](docs/tools/get_profiling_results.md) | Get profiling (performance measurement) results after a debug session: per-module, per-line call count, timing and percentage. Returns only the MOST RECENT m… | -| [`start_profiling`](docs/tools/start_profiling.md) | Start performance measurement on the active debug target. Enables line-level profiling: call counts and timing for every executed BSL line. Start-only and id… | -| [`stop_profiling`](docs/tools/stop_profiling.md) | Stop performance measurement on the active debug target. Counterpart to start_profiling: deterministically switches profiling off. Idempotent: if profiling i… | +| [`get_profiling_results`](docs/tools/get_profiling_results.md) | Identify performance hotspots in executed BSL code. Returns the MOST RECENT measurement session GLOBALLY - applicationId only changes the reported active-sta… | +| [`start_profiling`](docs/tools/start_profiling.md) | Measure execution time and coverage of BSL code in a debug session. Parameters and examples: get_tool_guide('start_profiling'). | +| [`stop_profiling`](docs/tools/stop_profiling.md) | Finish measuring BSL performance in a debug session. Parameters and examples: get_tool_guide('stop_profiling'). | ### Forms @@ -574,9 +574,9 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`get_form_layout_snapshot`](docs/tools/get_form_layout_snapshot.md) | Return a YAML snapshot of a form's calculated WYSIWYG layout (bounds, element types, display properties) as text; use it to inspect or compare what a form ac… | -| [`get_form_screenshot`](docs/tools/get_form_screenshot.md) | Capture a PNG screenshot of a form's WYSIWYG editor; pass formPath to open the form automatically or omit it to shoot the active editor. Requires EDT launche… | -| [`get_template_screenshot`](docs/tools/get_template_screenshot.md) | Capture a PNG screenshot of a 1C template (a SpreadsheetDocument print form) as EDT renders it, so its layout and text are visible to an AI. Works for a comm… | +| [`get_form_layout_snapshot`](docs/tools/get_form_layout_snapshot.md) | Inspect the calculated visual layout of an EDT form. Requires EDT launched with -DnativeFormBufferedLayoutRender=true: without the flag the layout comes back… | +| [`get_form_screenshot`](docs/tools/get_form_screenshot.md) | Visually inspect an EDT form as rendered by the designer. Requires EDT launched with -DnativeFormBufferedLayoutRender=true: without the flag the image comes… | +| [`get_template_screenshot`](docs/tools/get_template_screenshot.md) | Visually inspect how a spreadsheet print template renders. Parameters and examples: get_tool_guide('get_template_screenshot'). | ### Tags @@ -584,8 +584,8 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`get_objects_by_tags`](docs/tools/get_objects_by_tags.md) | Get metadata objects filtered by tags. Returns objects that have any of the specified tags, including tag descriptions and object FQNs. | -| [`get_tags`](docs/tools/get_tags.md) | Get list of all tags defined in the project. Tags are user-defined labels for organizing metadata objects. Returns tag name, color, description, and number o… | +| [`get_objects_by_tags`](docs/tools/get_objects_by_tags.md) | Find metadata objects organized under selected tags. Parameters and examples: get_tool_guide('get_objects_by_tags'). | +| [`get_tags`](docs/tools/get_tags.md) | Discover user-defined tags used to organize project metadata. Parameters and examples: get_tool_guide('get_tags'). | ### Translation @@ -593,9 +593,9 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`generate_translation_strings`](docs/tools/generate_translation_strings.md) | Generate translation strings (.lstr/.trans/.dict) for a configuration project: scans translatable features and writes the resulting keys into the project's s… | -| [`get_translation_project_info`](docs/tools/get_translation_project_info.md) | Return LanguageTool metadata for a project: the translation storages declared on it and the available translation provider IDs. Use it to check whether a dic… | -| [`translate_configuration`](docs/tools/translate_configuration.md) | Run EDT 'Translate configuration' on a configuration project - reads the dictionaries from the storages bound to it (external dictionary storage projects wit… | +| [`generate_translation_strings`](docs/tools/generate_translation_strings.md) | Collect translatable strings of a configuration and WRITE the generated keys into the project's translation storage (.lstr/.trans/.dict; storageId, default '… | +| [`get_translation_project_info`](docs/tools/get_translation_project_info.md) | Inspect the translation setup of an EDT project. Parameters and examples: get_tool_guide('get_translation_project_info'). | +| [`translate_configuration`](docs/tools/translate_configuration.md) | SYNCHRONIZE a 1C configuration's translated artifacts with the target languages. Does NOT translate anything itself: it regenerates the artifacts from transl… | ### Project @@ -603,31 +603,32 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`apply_quick_fix`](docs/tools/apply_quick_fix.md) | Apply EDT's official quick-fix (auto-fix) to one validation marker — the headless counterpart of the 'Quick Fix' action in the problems view. Address the mar… | -| [`build_external_objects`](docs/tools/build_external_objects.md) | Build (compile to disk) the external data processors/reports of an EDT external-object project to .epf/.erf files. Build ONE object with objectName, or ALL o… | -| [`clean_project`](docs/tools/clean_project.md) | Clean EDT project and trigger full revalidation. Direction: DISK -> MODEL - re-imports the on-disk src/ .mdo files into the in-memory model. Refreshes files… | -| [`create_git_branch`](docs/tools/create_git_branch.md) | Create a new local git branch, optionally check it out, and optionally attach an EXISTING infobase (application, from get_applications) to the new branch's c… | -| [`create_infobase`](docs/tools/create_infobase.md) | Create a new FILE infobase (1C database) OR register an existing one, and bind it to a configuration project so it appears in get_applications. mode='create'… | -| [`create_project`](docs/tools/create_project.md) | Create a NEW 1C project in the EDT workspace. projectKind selects the kind: 'configuration' (standalone), 'extension' (bound to a base configuration), or 'ex… | -| [`delete_infobase`](docs/tools/delete_infobase.md) | Remove a FILE infobase association from a configuration project OR delete a standalone (autonomous) server application. Destructive: guarded by a confirm-pre… | -| [`delete_project`](docs/tools/delete_project.md) | Remove an EDT project from the workspace, optionally deleting its files from disk (deleteContent). Destructive: guarded by a confirm-preview - call without c… | -| [`export_configuration_to_xml`](docs/tools/export_configuration_to_xml.md) | Export an EDT configuration project to XML files (EDT menu: Export -> Configuration to XML Files). Equivalent of 1C platform DumpConfigToFiles. | -| [`get_check_description`](docs/tools/get_check_description.md) | Get detailed description of an EDT check by its ID. Returns markdown content with check explanation, examples, and how to fix. Accepts the symbolic check id… | -| [`get_event_log`](docs/tools/get_event_log.md) | Read a 1C infobase event log WITHOUT a running 1C session by parsing the raw log files (legacy text ver 2.0: a 1Cv8.lgf dictionary + dated *.lgp partitions).… | -| [`get_markers`](docs/tools/get_markers.md) | List workspace markers: bookmarks and/or task markers (TODO, FIXME, XXX, HACK). Filter by markerKind (bookmark \| task; omit to list both), projectName, fileP… | -| [`get_mcp_history`](docs/tools/get_mcp_history.md) | Return the recorded MCP call history (this server's in-memory ring of request/response exchanges) so you can introspect your OWN traffic: which tools you cal… | -| [`get_platform_documentation`](docs/tools/get_platform_documentation.md) | Look up 1C:Enterprise platform documentation for built-in types (ValueTable, Array, Structure) and global built-in functions, including their methods, proper… | -| [`get_problem_summary`](docs/tools/get_problem_summary.md) | Get problem summary with counts grouped by project and EDT severity level (ERRORS, BLOCKER, CRITICAL, MAJOR, MINOR, TRIVIAL). Use this for severity totals on… | -| [`get_project_errors`](docs/tools/get_project_errors.md) | List EDT configuration problems (validation markers) with optional project / severity / check-id / object filters. Each row carries the check code, message,… | -| [`import_configuration_from_xml`](docs/tools/import_configuration_from_xml.md) | Import a configuration from a directory of XML files into a NEW EDT project (EDT menu: Import); the reverse of export_configuration_to_xml. The projectName m… | -| [`list_git_branches`](docs/tools/list_git_branches.md) | List a project's git branches: local and remote-tracking, with the CURRENT branch marked (detached HEAD flagged), plus the 1C application/infobase each branc… | -| [`resync_to_disk`](docs/tools/resync_to_disk.md) | Bulk re-synchronize the in-memory BM model to the on-disk src/ .mdo files and report BM-to-disk desync. Direction: MODEL -> DISK (writes the model out to src… | -| [`revalidate_objects`](docs/tools/revalidate_objects.md) | Revalidate EDT project or specific objects. If objects array is empty or missing, revalidates entire project. FQN examples: 'Document.SalesOrder', 'Catalog.P… | -| [`set_branch_infobase`](docs/tools/set_branch_infobase.md) | Attach or detach an EXISTING infobase (application) to/from a specific git branch context, so switch_git_branch's automatic binding follows that branch. Targ… | -| [`set_infobase_credentials`](docs/tools/set_infobase_credentials.md) | Store infobase connection credentials (user/password) so update_database and debug_launch can authenticate the update agent on an infobase that has a user li… | -| [`switch_git_branch`](docs/tools/switch_git_branch.md) | Switch a project's git repository to another branch (headless EGit checkout). branch may be a short local name (e.g. 'feature/x') or a full ref ('refs/heads/… | -| [`update_database`](docs/tools/update_database.md) | Apply configuration changes to an application's database (infobase), full or incremental. Target by launchConfigurationName (preferred) or projectName + appl… | -| [`validate_xdto_package`](docs/tools/validate_xdto_package.md) | Validate a single XDTO package by running EDT's OWN configuration validation (the same check engine behind get_project_errors) scoped to that package, and re… | +| [`apply_quick_fix`](docs/tools/apply_quick_fix.md) | Apply an EDT quick fix to a validation problem. Parameters and examples: get_tool_guide('apply_quick_fix'). | +| [`build_external_objects`](docs/tools/build_external_objects.md) | Compile external 1C data processors and reports into deployable files. NOT self-contained: the project needs an associated infobase AND a resolvable 1C runti… | +| [`clean_project`](docs/tools/clean_project.md) | Rebuild an EDT project from the on-disk src/ files and revalidate everything. Direction DISK -> MODEL; slow, and it DISCARDS unsaved in-memory model edits -… | +| [`code_review`](docs/tools/code_review.md) | Review BSL code quality with the BSL Language Server engine: its FULL diagnostic catalog (magic number, cyclomatic/cognitive complexity, method/line length,… | +| [`create_git_branch`](docs/tools/create_git_branch.md) | Start isolated work on a new Git branch for an EDT project. Parameters and examples: get_tool_guide('create_git_branch'). | +| [`create_infobase`](docs/tools/create_infobase.md) | Prepare an infobase for an EDT project by creating a database or registering an existing one. Passing user/password/access STORES those credentials in EDT's… | +| [`create_project`](docs/tools/create_project.md) | Start a new EDT configuration, extension, or external-objects project. Parameters and examples: get_tool_guide('create_project'). | +| [`delete_infobase`](docs/tools/delete_infobase.md) | Remove a project's infobase or its standalone-server registration, optionally deleting the database files. DESTRUCTIVE and IRREVERSIBLE. Two-phase: call once… | +| [`delete_project`](docs/tools/delete_project.md) | Remove an EDT project from the workspace, optionally with its sources on disk. DESTRUCTIVE and IRREVERSIBLE. Two-phase: call once WITHOUT confirm to preview,… | +| [`export_configuration_to_xml`](docs/tools/export_configuration_to_xml.md) | Export an EDT configuration into 1C XML files. Parameters and examples: get_tool_guide('export_configuration_to_xml'). | +| [`get_check_description`](docs/tools/get_check_description.md) | Understand an EDT validation rule and how to fix its diagnostic. Parameters and examples: get_tool_guide('get_check_description'). | +| [`get_event_log`](docs/tools/get_event_log.md) | Investigate infobase activity and errors through its event log. Reads the LEGACY text format only (ver 2.0: 1Cv8.lgf + *.lgp); an infobase on the modern SQLi… | +| [`get_markers`](docs/tools/get_markers.md) | Find bookmarks and TODO-style task markers in the workspace. Parameters and examples: get_tool_guide('get_markers'). | +| [`get_mcp_history`](docs/tools/get_mcp_history.md) | Diagnose MCP tool calls by reviewing recent requests, failures, timings, and context usage. Parameters and examples: get_tool_guide('get_mcp_history'). | +| [`get_platform_documentation`](docs/tools/get_platform_documentation.md) | Look up a built-in 1C type or global function in the platform documentation. Returns headers and member names by default - pass responseFormat='detailed' for… | +| [`get_problem_summary`](docs/tools/get_problem_summary.md) | See validation problem counts grouped by project and severity. Parameters and examples: get_tool_guide('get_problem_summary'). | +| [`get_project_errors`](docs/tools/get_project_errors.md) | Find detailed validation errors and warnings in an EDT project. Parameters and examples: get_tool_guide('get_project_errors'). | +| [`import_configuration_from_xml`](docs/tools/import_configuration_from_xml.md) | Create an EDT project from exported 1C configuration XML files. Parameters and examples: get_tool_guide('import_configuration_from_xml'). | +| [`list_git_branches`](docs/tools/list_git_branches.md) | Inspect available Git branches and their EDT infobase bindings. Parameters and examples: get_tool_guide('list_git_branches'). | +| [`resync_to_disk`](docs/tools/resync_to_disk.md) | Write the in-memory model back out to the on-disk src/ .mdo files and report model-to-disk desync; fixes 'object file does not exist' failures and dangling C… | +| [`revalidate_objects`](docs/tools/revalidate_objects.md) | Revalidate a project or a named list of objects, picking up .mdo edits made outside EDT. Targeted, lightweight alternative to clean_project - no full rebuild… | +| [`set_branch_infobase`](docs/tools/set_branch_infobase.md) | Associate an existing infobase with a Git branch of an EDT project. Parameters and examples: get_tool_guide('set_branch_infobase'). | +| [`set_infobase_credentials`](docs/tools/set_infobase_credentials.md) | STORE infobase credentials (user/password) in EDT settings so update_database and debug_launch can authenticate. The secret PERSISTS beyond this call, and ad… | +| [`switch_git_branch`](docs/tools/switch_git_branch.md) | Change the active version of an EDT project through Git branch checkout. Parameters and examples: get_tool_guide('switch_git_branch'). | +| [`update_database`](docs/tools/update_database.md) | Apply the current EDT configuration to an infobase. DESTRUCTIVE - restructures data and can evict live sessions. Two-phase: call once WITHOUT confirm to prev… | +| [`validate_xdto_package`](docs/tools/validate_xdto_package.md) | Check an XDTO package for configuration validation problems. Reads the markers EDT computed EARLIER - it does not validate on demand, so a verdict right afte… | ### Git @@ -635,7 +636,7 @@ with `python docs/generate_tool_docs.py`. | Tool | Description | |------|-------------| -| [`git`](docs/tools/git.md) | Run a git command in a project's repository - the non-UI equivalent of typing it in a terminal. Send it as a shell-style string (e.g. 'status', 'diff HEAD~1'… *(not enabled by default)* | +| [`git`](docs/tools/git.md) | Run a git command in a project's repository through the real git CLI, sent as a shell-style string. Only a whitelisted set of subcommands runs, and the write… | diff --git a/docs/tools/README.md b/docs/tools/README.md index 99380e128..0851dcf06 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -2,7 +2,7 @@ One page per tool: what it does, every parameter, and how it works. Generated from the live server by `docs/generate_tool_docs.py` (re-run to refresh; the source of truth is each tool's Java). -**89 tools.** +**90 tools.** ## Core @@ -10,18 +10,18 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`enable_toolset`](enable_toolset.md) | Reveal (or hide) tool groups for progressive disclosure. Pass toolsets=[ids] from list_toolsets to reveal them, then RE-REQUEST tools/list to see the newly r… | -| [`get_edt_version`](get_edt_version.md) | Returns the running 1C:EDT version as a plain version string. Returns "Unknown" when the version cannot be determined. | -| [`get_metadata_details`](get_metadata_details.md) | Get detailed properties of one or more 1C metadata objects (basic info by default, or every reflected section with 'full: true'). Use it after get_metadata_o… | -| [`get_metadata_objects`](get_metadata_objects.md) | Get a flat list of 1C configuration metadata objects (Name, Synonym, Comment, Type, ObjectModule, ManagerModule) as a Markdown table. Use it to discover what… | -| [`get_module_structure`](get_module_structure.md) | Get structure of a BSL module: all procedures/functions with signatures, line numbers, regions, execution context (&AtServer, &AtClient), export flag, and pa… | -| [`get_server_status`](get_server_status.md) | Self-diagnosis snapshot of the running MCP server: listening port, MCP protocol version, plugin version, EDT version, enabled/total tool counts, the plainTex… | -| [`get_tool_guide`](get_tool_guide.md) | Get the full on-demand how-to for a tool: its description, every parameter (type, required, allowed values) and extended examples/preconditions kept OUT of t… | -| [`list_modules`](list_modules.md) | List BSL modules in an EDT project as a table (module path, module type, parent type, parent name). Use it to discover module paths before reading or editing… | -| [`list_projects`](list_projects.md) | List all workspace projects with properties (name, path, type, natures). format='md' (default) returns the human Markdown table; format='json' returns the ma… | -| [`list_toolsets`](list_toolsets.md) | List the tool groups (toolsets) used by progressive tool disclosure: each toolset's id, title, description, member tools, and whether it is currently visible… | -| [`read_module_source`](read_module_source.md) | Read BSL module source code from an EDT project, whole file or a line range. Returns YAML frontmatter (including a contentHash revision token to round-trip i… | -| [`search_in_code`](search_in_code.md) | Literal/regex full-text search across all BSL modules in a project. Matching is purely textual and NOT ru/en dialect-aware, so a query in one BSL language wo… | +| [`enable_toolset`](enable_toolset.md) | Make additional MCP tool groups visible or hide them. Parameters and examples: get_tool_guide('enable_toolset'). | +| [`get_edt_version`](get_edt_version.md) | Identify the installed 1C:EDT version. Parameters and examples: get_tool_guide('get_edt_version'). | +| [`get_metadata_details`](get_metadata_details.md) | Inspect the properties and structure of a metadata object or member. Parameters and examples: get_tool_guide('get_metadata_details'). | +| [`get_metadata_objects`](get_metadata_objects.md) | Discover metadata objects available in a 1C configuration. Parameters and examples: get_tool_guide('get_metadata_objects'). | +| [`get_module_structure`](get_module_structure.md) | Discover procedures, functions, regions, and execution contexts in a BSL module. Parameters and examples: get_tool_guide('get_module_structure'). | +| [`get_server_status`](get_server_status.md) | Diagnose the EDT MCP server and its feature configuration. Parameters and examples: get_tool_guide('get_server_status'). | +| [`get_tool_guide`](get_tool_guide.md) | Retrieve detailed instructions for an MCP tool. Parameters and examples: get_tool_guide('get_tool_guide'). | +| [`list_modules`](list_modules.md) | Discover BSL modules available in an EDT project. Parameters and examples: get_tool_guide('list_modules'). | +| [`list_projects`](list_projects.md) | Discover projects available in the EDT workspace. Parameters and examples: get_tool_guide('list_projects'). | +| [`list_toolsets`](list_toolsets.md) | Discover available groups of MCP tools and their visibility. Parameters and examples: get_tool_guide('list_toolsets'). | +| [`read_module_source`](read_module_source.md) | Inspect the source of a complete BSL module. Parameters and examples: get_tool_guide('read_module_source'). | +| [`search_in_code`](search_in_code.md) | Literal/regex full-text search across BSL modules. Matching is textual and NOT ru/en dialect-aware, so a query in one BSL language will not find the other sp… | ## Metadata @@ -29,19 +29,19 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`adopt_metadata_object`](adopt_metadata_object.md) | Adopt a base-configuration metadata object or member (object / form / attribute / tabular section / ...) into a configuration EXTENSION so the extension can… | -| [`create_launch_config`](create_launch_config.md) | Create a 1C:EDT runtime-client launch configuration (thin/thick/web). The SAME config works for both run and debug (mode is chosen at launch time by debug_la… | -| [`create_metadata`](create_metadata.md) | Create a metadata node addressed by a 1C full-name FQN: a top-level object (Catalog.Products) or a subordinate member (Catalog.Products.Attribute.Weight, Inf… | -| [`delete_launch_config`](delete_launch_config.md) | Delete a 1C:EDT launch configuration by name (runtime client or Attach). Destructive: guarded by a confirm-preview - call without confirm to preview (no chan… | -| [`delete_metadata`](delete_metadata.md) | Delete a metadata node addressed by a 1C full-name FQN - a top object, an mdclass MEMBER (attribute / tabular section / dimension / resource / enum value), a… | -| [`export_common_picture`](export_common_picture.md) | Export a 1C CommonPicture (общая картинка) as PNG and list its picture variants (dpi, theme, interface variant, direction, template flag, glyph size). Resolv… | -| [`get_configuration_properties`](get_configuration_properties.md) | Get 1C:Enterprise configuration properties (name, synonym, comment, script variant, compatibility mode, etc.) | -| [`get_subsystem_content`](get_subsystem_content.md) | Get one 1C subsystem's content: properties, its metadata objects (Type/Name/Synonym/FQN) and child subsystems, identified by FQN (e.g. 'Subsystem.Sales.Subsy… | -| [`list_common_pictures`](list_common_pictures.md) | List a 1C configuration's CommonPicture objects and the variants each carries in its Picture.zip (DPI, theme, interface variant, template flag, glyph size, p… | -| [`list_configurations`](list_configurations.md) | List EDT launch configurations (runtime client + Attach + other 1C types) with their running state. This is the discovery step before debug_launch / run_yaxu… | -| [`list_subsystems`](list_subsystems.md) | List 1C subsystems of a configuration as a flat table (FQN, Synonym, Comment, InCommandInterface, content count, children count). Walks the whole tree by def… | -| [`modify_metadata`](modify_metadata.md) | Set properties of a metadata node - an object, a member, or a FORM member (item / attribute / command / handler) - addressed by a 1C full-name FQN, as proper… | -| [`rename_metadata_object`](rename_metadata_object.md) | Rename a metadata object, one of its members, or a managed-form element (attribute / command / field / button / group / decoration / table / attribute column… | +| [`adopt_metadata_object`](adopt_metadata_object.md) | Add a base-configuration object or member to an extension for customization. Parameters and examples: get_tool_guide('adopt_metadata_object'). | +| [`create_launch_config`](create_launch_config.md) | Configure an EDT runtime client for launching a 1C application. Parameters and examples: get_tool_guide('create_launch_config'). | +| [`create_metadata`](create_metadata.md) | Add a new metadata object or member to a configuration. Parameters and examples: get_tool_guide('create_metadata'). | +| [`delete_launch_config`](delete_launch_config.md) | Remove an unused EDT runtime or attach launch configuration. Two-phase: call once WITHOUT confirm to preview, then again with confirm=true to apply. Paramete… | +| [`delete_metadata`](delete_metadata.md) | Delete a metadata object or member (FQN-addressed). DESTRUCTIVE and CASCADING: on the md-refactoring path EDT cleans the REFERENCES to the deleted object acr… | +| [`export_common_picture`](export_common_picture.md) | Inspect or extract the image data of a 1C common picture. Parameters and examples: get_tool_guide('export_common_picture'). | +| [`get_configuration_properties`](get_configuration_properties.md) | Inspect the identity and compatibility settings of a 1C configuration. Parameters and examples: get_tool_guide('get_configuration_properties'). | +| [`get_subsystem_content`](get_subsystem_content.md) | Inspect which metadata objects and child subsystems belong to a 1C subsystem. Parameters and examples: get_tool_guide('get_subsystem_content'). | +| [`list_common_pictures`](list_common_pictures.md) | Inventory common pictures available in a 1C configuration. Parameters and examples: get_tool_guide('list_common_pictures'). | +| [`list_configurations`](list_configurations.md) | Discover EDT runtime and server-side launch configurations. Parameters and examples: get_tool_guide('list_configurations'). | +| [`list_subsystems`](list_subsystems.md) | Discover the subsystem hierarchy of a 1C configuration. Parameters and examples: get_tool_guide('list_subsystems'). | +| [`modify_metadata`](modify_metadata.md) | Set properties of any metadata node (object or member, including form items, attributes, commands, and handlers). Parameters and examples: get_tool_guide('mo… | +| [`rename_metadata_object`](rename_metadata_object.md) | Rename a metadata object or member and rewrite the references EDT RESOLVES for it. CASCADES ACROSS THE WHOLE CONFIGURATION - BSL, forms, roles, subsystems -… | ## Code @@ -49,15 +49,15 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`find_references`](find_references.md) | Find every place a metadata object is used: BSL code modules (with line numbers), other metadata, forms, roles, subsystems, etc. Pass the object FQN; the typ… | -| [`get_content_assist`](get_content_assist.md) | Get code-completion proposals at a 1-based line/column in a BSL module - the members, globals and variables valid at that caret (e.g. after a '.'). May retur… | +| [`find_references`](find_references.md) | Discover where a metadata object is used throughout the configuration and BSL code. Parameters and examples: get_tool_guide('find_references'). | +| [`get_content_assist`](get_content_assist.md) | Find valid BSL completion suggestions at a source-code position. Parameters and examples: get_tool_guide('get_content_assist'). | | [`get_method_call_hierarchy`](get_method_call_hierarchy.md) | Trace which BSL methods call a method or are called by it; optional depth walks the chain transitively for impact analysis (callers only, max 5). Finds STATI… | -| [`get_outgoing_structures`](get_outgoing_structures.md) | For each outgoing qualified call in a BSL module (or one method), report the top-level literal keys of the Structure passed as its first argument (local .Ins… | -| [`get_symbol_info`](get_symbol_info.md) | Get type/hover info about a symbol at a position in a BSL module. Returns inferred types, signatures, and documentation. | -| [`go_to_definition`](go_to_definition.md) | Go to the definition of a symbol (the inverse of find_references): a qualified method 'ModuleName.MethodName', a bare 'MethodName' (also pass modulePath), or… | -| [`read_method_source`](read_method_source.md) | Read a specific procedure/function from a BSL module by name. Returns source code with metadata. Lists available methods if not found. Use this for one metho… | -| [`validate_query`](validate_query.md) | Validate 1C:Enterprise query language (QL) text against a project, returning syntax and semantic errors with line numbers. Use to check a query before embedd… | -| [`write_module_source`](write_module_source.md) | Write BSL source code to a 1C metadata object module. Use to edit a module: searchReplace a fragment (default, needs oldSource), replace the whole file, or a… | +| [`get_outgoing_structures`](get_outgoing_structures.md) | Discover the fields passed to outgoing or qualified BSL method calls. BEST-EFFORT and incomplete by design: only top-level LITERAL keys of the first argument… | +| [`get_symbol_info`](get_symbol_info.md) | Inspect the type and documentation of a BSL symbol at its source location. Parameters and examples: get_tool_guide('get_symbol_info'). | +| [`go_to_definition`](go_to_definition.md) | Locate the source definition of a BSL symbol or metadata object. Parameters and examples: get_tool_guide('go_to_definition'). | +| [`read_method_source`](read_method_source.md) | Inspect the source of one BSL procedure or function. Parameters and examples: get_tool_guide('read_method_source'). | +| [`validate_query`](validate_query.md) | Check a 1C query for syntax and metadata-reference errors. Parameters and examples: get_tool_guide('validate_query'). | +| [`write_module_source`](write_module_source.md) | Create or edit BSL source in a metadata module. Parameters and examples: get_tool_guide('write_module_source'). | ## Debug @@ -65,19 +65,19 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`debug_launch`](debug_launch.md) | Start an EDT debug session: either an existing config by launchConfigurationName (runtime client OR Attach, the latter needed to debug server-side code), or… | -| [`debug_status`](debug_status.md) | Report active debug sessions: applicationId (real or synthetic 'attach:' / 'launch:'), launch configuration name/type, mode (debug/run), whether… | -| [`evaluate_expression`](evaluate_expression.md) | Evaluate a BSL expression in the context of a suspended stack frame. Pass frameRef from wait_for_break and the expression text. WARNING: this executes arbitr… | -| [`get_applications`](get_applications.md) | Get list of applications (infobases) for a project. Returns application ID, name, type, and update state. Application ID is required for update_database and… | -| [`get_variables`](get_variables.md) | Read variables from a stack frame of a suspended debug thread. Pass frameRef from wait_for_break (preferred) or threadId+frameIndex. Use expandPath to drill… | -| [`list_breakpoints`](list_breakpoints.md) | List active line breakpoints. Optionally filter by projectName. | -| [`remove_breakpoint`](remove_breakpoint.md) | Remove a 1C BSL line breakpoint. Either pass breakpointId (returned from set_breakpoint) or projectName+module+lineNumber to look it up by coordinates. | -| [`resume`](resume.md) | Resume a suspended debug thread or all threads of a debug target. Pass threadId (from wait_for_break) or applicationId. applicationId accepts ANY id form for… | -| [`set_breakpoint`](set_breakpoint.md) | Set a line breakpoint on a 1C BSL module. Accepts either an EDT module-relative path (e.g. 'CommonModules/Foo/Module.bsl') or an absolute filesystem path. Us… | -| [`set_variable`](set_variable.md) | Set a BSL variable's value in a suspended debug frame. WRITE/side-effect: EXECUTES the entered value as a BSL literal/expression live in the running 1C appli… | -| [`step`](step.md) | Step a suspended debug thread. kind ∈ {over, into, out}. Blocks until the next SUSPEND event (or timeout) and returns the new frame snapshot. | -| [`terminate_launch`](terminate_launch.md) | Terminate one or more 1C launches started from THIS EDT instance; externally launched 1C clients are never touched. Select ONE target mode: launchConfigurati… | -| [`wait_for_break`](wait_for_break.md) | Wait for a debug suspend event (e.g. breakpoint hit) on the given application. Returns the suspended thread/frame snapshot, or {hit:false} on timeout. applic… | +| [`debug_launch`](debug_launch.md) | Run a 1C application under EDT debugging. An already-running session is NOT relaunched - the call short-circuits with alreadyRunning:true; restartIfRunning=t… | +| [`debug_status`](debug_status.md) | Check which EDT debug sessions are running or paused. Parameters and examples: get_tool_guide('debug_status'). | +| [`evaluate_expression`](evaluate_expression.md) | Evaluate a BSL expression in a paused debug frame and return the value. WARNING: this executes arbitrary code in the running application - it can change stat… | +| [`get_applications`](get_applications.md) | Discover infobases connected to an EDT project. Parameters and examples: get_tool_guide('get_applications'). | +| [`get_variables`](get_variables.md) | Inspect runtime variables in a paused debug frame. Parameters and examples: get_tool_guide('get_variables'). | +| [`list_breakpoints`](list_breakpoints.md) | Review breakpoints currently set in BSL source code. Parameters and examples: get_tool_guide('list_breakpoints'). | +| [`remove_breakpoint`](remove_breakpoint.md) | Stop pausing execution at a BSL source breakpoint. Address it EITHER by breakpointId OR by modulePath together with lineNumber - every field is optional on i… | +| [`resume`](resume.md) | Continue a paused 1C debug session. Parameters and examples: get_tool_guide('resume'). | +| [`set_breakpoint`](set_breakpoint.md) | Pause BSL execution at a selected source line during debugging. Parameters and examples: get_tool_guide('set_breakpoint'). | +| [`set_variable`](set_variable.md) | Change a variable while execution is paused in the debugger. WARNING: the value is EVALUATED as a BSL expression in the running application, so it can invoke… | +| [`step`](step.md) | Advance paused debug execution one step. Parameters and examples: get_tool_guide('step'). | +| [`terminate_launch`](terminate_launch.md) | Stop 1C sessions started by EDT. NOT two-phase for a single launch: selecting one (launchConfigurationName, or projectName + applicationId) stops it IMMEDIAT… | +| [`wait_for_break`](wait_for_break.md) | Wait until a running 1C debug session reaches a breakpoint or other suspend event. Parameters and examples: get_tool_guide('wait_for_break'). | ## Testing @@ -85,11 +85,11 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`ask_workmate`](ask_workmate.md) | Start a background question to the 1C:Workmate plugin and return its jobId. Poll the job with get_job_status instead of calling ask_workmate again. Requires… *(not enabled by default)* | -| [`cancel_job`](cancel_job.md) | Preview or cancel a background job by jobId. Destructive: omitting confirm or passing confirm=false only describes the owning tool, state, and progress; conf… | -| [`debug_yaxunit_tests`](debug_yaxunit_tests.md) | Deprecated alias for run_yaxunit_tests with debug=true. Launches YAXUnit tests in DEBUG mode so breakpoints fire, then call wait_for_break to inspect. Prefer… | -| [`get_job_status`](get_job_status.md) | Poll any background job by the jobId returned from its owning tool. Returns the current state, progress journal, and terminal result; optionally waits for a… | -| [`run_yaxunit_tests`](run_yaxunit_tests.md) | Run YAXUnit tests as a named background job and return a JUnit Markdown report. The start call waits up to `timeout` (default and maximum 45s, larger values… | +| [`ask_workmate`](ask_workmate.md) | Start a background question to the 1C:Workmate plugin and return its jobId. Hands the question to an EXTERNAL agent: by default (shareMcpTools) Workmate may… *(not enabled by default)* | +| [`cancel_job`](cancel_job.md) | Cancel a background job by jobId. DESTRUCTIVE. Two-phase: call once WITHOUT confirm to see the owning tool, state and progress, then again with confirm=true… | +| [`debug_yaxunit_tests`](debug_yaxunit_tests.md) | DEPRECATED alias of run_yaxunit_tests(debug=true) - prefer that instead; the implementation is shared. DEBUG mode, so breakpoints fire: a short start returns… | +| [`get_job_status`](get_job_status.md) | Poll any background job by the jobId its owning tool returned: state, progress journal and terminal result. Parameters and examples: get_tool_guide('get_job_… | +| [`run_yaxunit_tests`](run_yaxunit_tests.md) | Run YAXUnit tests as a named background job and return a JUnit Markdown report. The start call waits up to `timeout` (default and maximum 45s): a short run r… | ## Profiling @@ -97,9 +97,9 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`get_profiling_results`](get_profiling_results.md) | Get profiling (performance measurement) results after a debug session: per-module, per-line call count, timing and percentage. Returns only the MOST RECENT m… | -| [`start_profiling`](start_profiling.md) | Start performance measurement on the active debug target. Enables line-level profiling: call counts and timing for every executed BSL line. Start-only and id… | -| [`stop_profiling`](stop_profiling.md) | Stop performance measurement on the active debug target. Counterpart to start_profiling: deterministically switches profiling off. Idempotent: if profiling i… | +| [`get_profiling_results`](get_profiling_results.md) | Identify performance hotspots in executed BSL code. Returns the MOST RECENT measurement session GLOBALLY - applicationId only changes the reported active-sta… | +| [`start_profiling`](start_profiling.md) | Measure execution time and coverage of BSL code in a debug session. Parameters and examples: get_tool_guide('start_profiling'). | +| [`stop_profiling`](stop_profiling.md) | Finish measuring BSL performance in a debug session. Parameters and examples: get_tool_guide('stop_profiling'). | ## Forms @@ -107,9 +107,9 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`get_form_layout_snapshot`](get_form_layout_snapshot.md) | Return a YAML snapshot of a form's calculated WYSIWYG layout (bounds, element types, display properties) as text; use it to inspect or compare what a form ac… | -| [`get_form_screenshot`](get_form_screenshot.md) | Capture a PNG screenshot of a form's WYSIWYG editor; pass formPath to open the form automatically or omit it to shoot the active editor. Requires EDT launche… | -| [`get_template_screenshot`](get_template_screenshot.md) | Capture a PNG screenshot of a 1C template (a SpreadsheetDocument print form) as EDT renders it, so its layout and text are visible to an AI. Works for a comm… | +| [`get_form_layout_snapshot`](get_form_layout_snapshot.md) | Inspect the calculated visual layout of an EDT form. Requires EDT launched with -DnativeFormBufferedLayoutRender=true: without the flag the layout comes back… | +| [`get_form_screenshot`](get_form_screenshot.md) | Visually inspect an EDT form as rendered by the designer. Requires EDT launched with -DnativeFormBufferedLayoutRender=true: without the flag the image comes… | +| [`get_template_screenshot`](get_template_screenshot.md) | Visually inspect how a spreadsheet print template renders. Parameters and examples: get_tool_guide('get_template_screenshot'). | ## Tags @@ -117,8 +117,8 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`get_objects_by_tags`](get_objects_by_tags.md) | Get metadata objects filtered by tags. Returns objects that have any of the specified tags, including tag descriptions and object FQNs. | -| [`get_tags`](get_tags.md) | Get list of all tags defined in the project. Tags are user-defined labels for organizing metadata objects. Returns tag name, color, description, and number o… | +| [`get_objects_by_tags`](get_objects_by_tags.md) | Find metadata objects organized under selected tags. Parameters and examples: get_tool_guide('get_objects_by_tags'). | +| [`get_tags`](get_tags.md) | Discover user-defined tags used to organize project metadata. Parameters and examples: get_tool_guide('get_tags'). | ## Translation @@ -126,9 +126,9 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`generate_translation_strings`](generate_translation_strings.md) | Generate translation strings (.lstr/.trans/.dict) for a configuration project: scans translatable features and writes the resulting keys into the project's s… | -| [`get_translation_project_info`](get_translation_project_info.md) | Return LanguageTool metadata for a project: the translation storages declared on it and the available translation provider IDs. Use it to check whether a dic… | -| [`translate_configuration`](translate_configuration.md) | Run EDT 'Translate configuration' on a configuration project - reads the dictionaries from the storages bound to it (external dictionary storage projects wit… | +| [`generate_translation_strings`](generate_translation_strings.md) | Collect translatable strings of a configuration and WRITE the generated keys into the project's translation storage (.lstr/.trans/.dict; storageId, default '… | +| [`get_translation_project_info`](get_translation_project_info.md) | Inspect the translation setup of an EDT project. Parameters and examples: get_tool_guide('get_translation_project_info'). | +| [`translate_configuration`](translate_configuration.md) | SYNCHRONIZE a 1C configuration's translated artifacts with the target languages. Does NOT translate anything itself: it regenerates the artifacts from transl… | ## Project @@ -136,31 +136,32 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`apply_quick_fix`](apply_quick_fix.md) | Apply EDT's official quick-fix (auto-fix) to one validation marker — the headless counterpart of the 'Quick Fix' action in the problems view. Address the mar… | -| [`build_external_objects`](build_external_objects.md) | Build (compile to disk) the external data processors/reports of an EDT external-object project to .epf/.erf files. Build ONE object with objectName, or ALL o… | -| [`clean_project`](clean_project.md) | Clean EDT project and trigger full revalidation. Direction: DISK -> MODEL - re-imports the on-disk src/ .mdo files into the in-memory model. Refreshes files… | -| [`create_git_branch`](create_git_branch.md) | Create a new local git branch, optionally check it out, and optionally attach an EXISTING infobase (application, from get_applications) to the new branch's c… | -| [`create_infobase`](create_infobase.md) | Create a new FILE infobase (1C database) OR register an existing one, and bind it to a configuration project so it appears in get_applications. mode='create'… | -| [`create_project`](create_project.md) | Create a NEW 1C project in the EDT workspace. projectKind selects the kind: 'configuration' (standalone), 'extension' (bound to a base configuration), or 'ex… | -| [`delete_infobase`](delete_infobase.md) | Remove a FILE infobase association from a configuration project OR delete a standalone (autonomous) server application. Destructive: guarded by a confirm-pre… | -| [`delete_project`](delete_project.md) | Remove an EDT project from the workspace, optionally deleting its files from disk (deleteContent). Destructive: guarded by a confirm-preview - call without c… | -| [`export_configuration_to_xml`](export_configuration_to_xml.md) | Export an EDT configuration project to XML files (EDT menu: Export -> Configuration to XML Files). Equivalent of 1C platform DumpConfigToFiles. | -| [`get_check_description`](get_check_description.md) | Get detailed description of an EDT check by its ID. Returns markdown content with check explanation, examples, and how to fix. Accepts the symbolic check id… | -| [`get_event_log`](get_event_log.md) | Read a 1C infobase event log WITHOUT a running 1C session by parsing the raw log files (legacy text ver 2.0: a 1Cv8.lgf dictionary + dated *.lgp partitions).… | -| [`get_markers`](get_markers.md) | List workspace markers: bookmarks and/or task markers (TODO, FIXME, XXX, HACK). Filter by markerKind (bookmark \| task; omit to list both), projectName, fileP… | -| [`get_mcp_history`](get_mcp_history.md) | Return the recorded MCP call history (this server's in-memory ring of request/response exchanges) so you can introspect your OWN traffic: which tools you cal… | -| [`get_platform_documentation`](get_platform_documentation.md) | Look up 1C:Enterprise platform documentation for built-in types (ValueTable, Array, Structure) and global built-in functions, including their methods, proper… | -| [`get_problem_summary`](get_problem_summary.md) | Get problem summary with counts grouped by project and EDT severity level (ERRORS, BLOCKER, CRITICAL, MAJOR, MINOR, TRIVIAL). Use this for severity totals on… | -| [`get_project_errors`](get_project_errors.md) | List EDT configuration problems (validation markers) with optional project / severity / check-id / object filters. Each row carries the check code, message,… | -| [`import_configuration_from_xml`](import_configuration_from_xml.md) | Import a configuration from a directory of XML files into a NEW EDT project (EDT menu: Import); the reverse of export_configuration_to_xml. The projectName m… | -| [`list_git_branches`](list_git_branches.md) | List a project's git branches: local and remote-tracking, with the CURRENT branch marked (detached HEAD flagged), plus the 1C application/infobase each branc… | -| [`resync_to_disk`](resync_to_disk.md) | Bulk re-synchronize the in-memory BM model to the on-disk src/ .mdo files and report BM-to-disk desync. Direction: MODEL -> DISK (writes the model out to src… | -| [`revalidate_objects`](revalidate_objects.md) | Revalidate EDT project or specific objects. If objects array is empty or missing, revalidates entire project. FQN examples: 'Document.SalesOrder', 'Catalog.P… | -| [`set_branch_infobase`](set_branch_infobase.md) | Attach or detach an EXISTING infobase (application) to/from a specific git branch context, so switch_git_branch's automatic binding follows that branch. Targ… | -| [`set_infobase_credentials`](set_infobase_credentials.md) | Store infobase connection credentials (user/password) so update_database and debug_launch can authenticate the update agent on an infobase that has a user li… | -| [`switch_git_branch`](switch_git_branch.md) | Switch a project's git repository to another branch (headless EGit checkout). branch may be a short local name (e.g. 'feature/x') or a full ref ('refs/heads/… | -| [`update_database`](update_database.md) | Apply configuration changes to an application's database (infobase), full or incremental. Target by launchConfigurationName (preferred) or projectName + appl… | -| [`validate_xdto_package`](validate_xdto_package.md) | Validate a single XDTO package by running EDT's OWN configuration validation (the same check engine behind get_project_errors) scoped to that package, and re… | +| [`apply_quick_fix`](apply_quick_fix.md) | Apply an EDT quick fix to a validation problem. Parameters and examples: get_tool_guide('apply_quick_fix'). | +| [`build_external_objects`](build_external_objects.md) | Compile external 1C data processors and reports into deployable files. NOT self-contained: the project needs an associated infobase AND a resolvable 1C runti… | +| [`clean_project`](clean_project.md) | Rebuild an EDT project from the on-disk src/ files and revalidate everything. Direction DISK -> MODEL; slow, and it DISCARDS unsaved in-memory model edits -… | +| [`code_review`](code_review.md) | Review BSL code quality with the BSL Language Server engine: its FULL diagnostic catalog (magic number, cyclomatic/cognitive complexity, method/line length,… | +| [`create_git_branch`](create_git_branch.md) | Start isolated work on a new Git branch for an EDT project. Parameters and examples: get_tool_guide('create_git_branch'). | +| [`create_infobase`](create_infobase.md) | Prepare an infobase for an EDT project by creating a database or registering an existing one. Passing user/password/access STORES those credentials in EDT's… | +| [`create_project`](create_project.md) | Start a new EDT configuration, extension, or external-objects project. Parameters and examples: get_tool_guide('create_project'). | +| [`delete_infobase`](delete_infobase.md) | Remove a project's infobase or its standalone-server registration, optionally deleting the database files. DESTRUCTIVE and IRREVERSIBLE. Two-phase: call once… | +| [`delete_project`](delete_project.md) | Remove an EDT project from the workspace, optionally with its sources on disk. DESTRUCTIVE and IRREVERSIBLE. Two-phase: call once WITHOUT confirm to preview,… | +| [`export_configuration_to_xml`](export_configuration_to_xml.md) | Export an EDT configuration into 1C XML files. Parameters and examples: get_tool_guide('export_configuration_to_xml'). | +| [`get_check_description`](get_check_description.md) | Understand an EDT validation rule and how to fix its diagnostic. Parameters and examples: get_tool_guide('get_check_description'). | +| [`get_event_log`](get_event_log.md) | Investigate infobase activity and errors through its event log. Reads the LEGACY text format only (ver 2.0: 1Cv8.lgf + *.lgp); an infobase on the modern SQLi… | +| [`get_markers`](get_markers.md) | Find bookmarks and TODO-style task markers in the workspace. Parameters and examples: get_tool_guide('get_markers'). | +| [`get_mcp_history`](get_mcp_history.md) | Diagnose MCP tool calls by reviewing recent requests, failures, timings, and context usage. Parameters and examples: get_tool_guide('get_mcp_history'). | +| [`get_platform_documentation`](get_platform_documentation.md) | Look up a built-in 1C type or global function in the platform documentation. Returns headers and member names by default - pass responseFormat='detailed' for… | +| [`get_problem_summary`](get_problem_summary.md) | See validation problem counts grouped by project and severity. Parameters and examples: get_tool_guide('get_problem_summary'). | +| [`get_project_errors`](get_project_errors.md) | Find detailed validation errors and warnings in an EDT project. Parameters and examples: get_tool_guide('get_project_errors'). | +| [`import_configuration_from_xml`](import_configuration_from_xml.md) | Create an EDT project from exported 1C configuration XML files. Parameters and examples: get_tool_guide('import_configuration_from_xml'). | +| [`list_git_branches`](list_git_branches.md) | Inspect available Git branches and their EDT infobase bindings. Parameters and examples: get_tool_guide('list_git_branches'). | +| [`resync_to_disk`](resync_to_disk.md) | Write the in-memory model back out to the on-disk src/ .mdo files and report model-to-disk desync; fixes 'object file does not exist' failures and dangling C… | +| [`revalidate_objects`](revalidate_objects.md) | Revalidate a project or a named list of objects, picking up .mdo edits made outside EDT. Targeted, lightweight alternative to clean_project - no full rebuild… | +| [`set_branch_infobase`](set_branch_infobase.md) | Associate an existing infobase with a Git branch of an EDT project. Parameters and examples: get_tool_guide('set_branch_infobase'). | +| [`set_infobase_credentials`](set_infobase_credentials.md) | STORE infobase credentials (user/password) in EDT settings so update_database and debug_launch can authenticate. The secret PERSISTS beyond this call, and ad… | +| [`switch_git_branch`](switch_git_branch.md) | Change the active version of an EDT project through Git branch checkout. Parameters and examples: get_tool_guide('switch_git_branch'). | +| [`update_database`](update_database.md) | Apply the current EDT configuration to an infobase. DESTRUCTIVE - restructures data and can evict live sessions. Two-phase: call once WITHOUT confirm to prev… | +| [`validate_xdto_package`](validate_xdto_package.md) | Check an XDTO package for configuration validation problems. Reads the markers EDT computed EARLIER - it does not validate on demand, so a verdict right afte… | ## Git @@ -168,4 +169,4 @@ One page per tool: what it does, every parameter, and how it works. Generated fr | Tool | Description | |------|-------------| -| [`git`](git.md) | Run a git command in a project's repository - the non-UI equivalent of typing it in a terminal. Send it as a shell-style string (e.g. 'status', 'diff HEAD~1'… *(not enabled by default)* | +| [`git`](git.md) | Run a git command in a project's repository through the real git CLI, sent as a shell-style string. Only a whitelisted set of subcommands runs, and the write… | diff --git a/docs/tools/code_review.md b/docs/tools/code_review.md index e28f7da2a..003f4622b 100644 --- a/docs/tools/code_review.md +++ b/docs/tools/code_review.md @@ -6,7 +6,7 @@ Review BSL code quality with the BSL Language Server engine: its FULL diagnostic | Parameter | Required | Type | Description | | --- | --- | --- | --- | | projectName | yes | string | EDT project name to review. | -| modulePath | — | string | Optional: narrow the review to a single module, path from src/ (e.g. 'CommonModules/Calc/Module.bsl'). Omit to review the whole configuration. | +| modulePath | — | string | Optional: narrow the review to a single module, path from src/ (e.g. 'CommonModules/Calc/Module.bsl'); must be a .bsl module. Omit to review the whole configuration - a scoped run cannot see cross-module context, so rules like unused-export are only reliable without it. | | severity | — | string (one of: error, warning, information, hint) | Optional: minimum severity to report (error > warning > information > hint). Omit to report all. | | rule | — | string | Optional: report only diagnostics whose rule id contains this substring (e.g. 'Magic', 'Complexity'). | | excludeRule | — | string | Optional: drop diagnostics whose rule id contains this substring — e.g. to exclude rules you already get from get_project_errors and avoid double-reporting the same issue. | @@ -28,7 +28,8 @@ The rows are defects to FIX, not just a report: ## Parameter details - `projectName` (required) — the EDT project to review. -- `modulePath` — narrow the review to a single module, given as a path from `src/` (e.g. `CommonModules/Calc/Module.bsl`). Omit to review the whole configuration. This is the same path form the `Module path` column returns, so you can feed a row straight back in. +- `modulePath` — narrow the review to a single module, given as a path from `src/` (e.g. `CommonModules/Calc/Module.bsl`). Must be a `.bsl` module; a metadata or template file is rejected rather than reviewed to an empty (falsely clean) result. Omit to review the whole configuration. This is the same path form the `Module path` column returns, so you can feed a row straight back in. + **Narrowing also narrows what the engine can SEE.** A scoped run analyses only that module's folder, so diagnostics that need project-wide context — an exported method reported unused because its only caller lives in another object, for example — can be wrong in a scoped run and right in a full one. Use `modulePath` for fast iteration while fixing; confirm cross-module findings with a full-project run (omit `modulePath`). - `severity` — minimum severity to report: `error` > `warning` > `information` > `hint`. Omit to report every severity. (These are the engine's LSP severities, independent of EDT's BLOCKER/MAJOR/… taxonomy.) - `rule` — report only diagnostics whose rule id contains this substring, case-insensitive (e.g. `Magic`, `Complexity`, `Unused`). Handy for a focused pass or a targeted re-verify. - `excludeRule` — drop diagnostics whose rule id contains this substring, case-insensitive — e.g. to exclude rules you already get from `get_project_errors` and avoid reviewing the same issue twice. @@ -46,7 +47,8 @@ The rows are defects to FIX, not just a report: - If the jar or Java cannot be found, the tool returns an actionable error naming the environment variable to set and the download page. ## Which checks run -- The engine reads the project's own `.bsl-language-server.json` (at the project root) if present; otherwise a `.bsl-language-server.json` sitting next to the jar (the "engine home"); otherwise the engine defaults. +- The engine reads the project's own `.bsl-language-server.json` (at the project root) if present; otherwise one next to the jar (the "engine home"); otherwise one the engine would find on its own — in the project's `src/` (the directory the engine runs in) or in your home directory; otherwise the engine defaults. +- **`traceLog` is stripped** from whatever config is used. The engine writes that log relative to its working directory, which is inside the project — and `code_review` is a read-only tool, so it must not leave files behind. Every other setting is passed through untouched. If you need the trace, run the engine yourself outside EDT. - Use that file to enable/disable rules (`"parameters": { "SomeRule": false }`), tune thresholds (`"MagicNumber": { "authorizedNumbers": "-1,0,1" }`) and set the message language (`"diagnosticLanguage": "en"`). Exact per-rule parameter names are on each rule's documentation page (the `Docs` column URL). ## Examples @@ -60,9 +62,10 @@ The rows are defects to FIX, not just a report: - Line numbers are 1-based (converted from the engine's 0-based LSP output), matching `read_module_source`/`set_breakpoint`. - `Module path` is relativized to `src/`; a finding outside `src/` (rare) shows its absolute path instead. - The engine analyzes files on disk. If you just edited a module through the model, ensure it is exported to disk (the write tools do this) before reviewing, or the review may read a stale file. -- A large configuration can take a while to analyze; scope with `modulePath` for quick iterative checks. +- **The whole-project run is bounded at 45 s** — deliberately under what the MCP transport will hold open, so a configuration too large to finish in that window returns this tool's own explanation instead of a bare transport timeout. Scope with `modulePath` for anything that big; a configuration that cannot be analysed within the window cannot be reviewed whole through MCP at all. - The engine's report is capped at 50 MB; a report larger than that (a pathological run, or a misconfigured/corrupt engine process) is rejected with an actionable error instead of being read into memory — narrow the scope with `modulePath` and re-run. - A `modulePath` must resolve INSIDE the requested project's own `src/` — an absolute path or one using `..` to point elsewhere is rejected. +- A scoped run is not a smaller version of the full run: see `modulePath` above — rules needing cross-module context are only reliable without `modulePath`. --- *Generated from the live MCP server (`get_tool_guide`) by `docs/generate_tool_docs.py`. Do not edit this file. Edit the tool's description/schema in its Java source and its guide body in `mcp/bundles/com.ditrix.edt.mcp.server/guides/.md`.* diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md b/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md index 77861518a..1214502fb 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md +++ b/mcp/bundles/com.ditrix.edt.mcp.server/guides/code_review.md @@ -13,7 +13,8 @@ The rows are defects to FIX, not just a report: ## Parameter details - `projectName` (required) — the EDT project to review. -- `modulePath` — narrow the review to a single module, given as a path from `src/` (e.g. `CommonModules/Calc/Module.bsl`). Omit to review the whole configuration. This is the same path form the `Module path` column returns, so you can feed a row straight back in. +- `modulePath` — narrow the review to a single module, given as a path from `src/` (e.g. `CommonModules/Calc/Module.bsl`). Must be a `.bsl` module; a metadata or template file is rejected rather than reviewed to an empty (falsely clean) result. Omit to review the whole configuration. This is the same path form the `Module path` column returns, so you can feed a row straight back in. + **Narrowing also narrows what the engine can SEE.** A scoped run analyses only that module's folder, so diagnostics that need project-wide context — an exported method reported unused because its only caller lives in another object, for example — can be wrong in a scoped run and right in a full one. Use `modulePath` for fast iteration while fixing; confirm cross-module findings with a full-project run (omit `modulePath`). - `severity` — minimum severity to report: `error` > `warning` > `information` > `hint`. Omit to report every severity. (These are the engine's LSP severities, independent of EDT's BLOCKER/MAJOR/… taxonomy.) - `rule` — report only diagnostics whose rule id contains this substring, case-insensitive (e.g. `Magic`, `Complexity`, `Unused`). Handy for a focused pass or a targeted re-verify. - `excludeRule` — drop diagnostics whose rule id contains this substring, case-insensitive — e.g. to exclude rules you already get from `get_project_errors` and avoid reviewing the same issue twice. @@ -31,7 +32,8 @@ The rows are defects to FIX, not just a report: - If the jar or Java cannot be found, the tool returns an actionable error naming the environment variable to set and the download page. ## Which checks run -- The engine reads the project's own `.bsl-language-server.json` (at the project root) if present; otherwise a `.bsl-language-server.json` sitting next to the jar (the "engine home"); otherwise the engine defaults. +- The engine reads the project's own `.bsl-language-server.json` (at the project root) if present; otherwise one next to the jar (the "engine home"); otherwise one the engine would find on its own — in the project's `src/` (the directory the engine runs in) or in your home directory; otherwise the engine defaults. +- **`traceLog` is stripped** from whatever config is used. The engine writes that log relative to its working directory, which is inside the project — and `code_review` is a read-only tool, so it must not leave files behind. Every other setting is passed through untouched. If you need the trace, run the engine yourself outside EDT. - Use that file to enable/disable rules (`"parameters": { "SomeRule": false }`), tune thresholds (`"MagicNumber": { "authorizedNumbers": "-1,0,1" }`) and set the message language (`"diagnosticLanguage": "en"`). Exact per-rule parameter names are on each rule's documentation page (the `Docs` column URL). ## Examples @@ -45,6 +47,7 @@ The rows are defects to FIX, not just a report: - Line numbers are 1-based (converted from the engine's 0-based LSP output), matching `read_module_source`/`set_breakpoint`. - `Module path` is relativized to `src/`; a finding outside `src/` (rare) shows its absolute path instead. - The engine analyzes files on disk. If you just edited a module through the model, ensure it is exported to disk (the write tools do this) before reviewing, or the review may read a stale file. -- A large configuration can take a while to analyze; scope with `modulePath` for quick iterative checks. +- **The whole-project run is bounded at 45 s** — deliberately under what the MCP transport will hold open, so a configuration too large to finish in that window returns this tool's own explanation instead of a bare transport timeout. Scope with `modulePath` for anything that big; a configuration that cannot be analysed within the window cannot be reviewed whole through MCP at all. - The engine's report is capped at 50 MB; a report larger than that (a pathological run, or a misconfigured/corrupt engine process) is rejected with an actionable error instead of being read into memory — narrow the scope with `modulePath` and re-run. - A `modulePath` must resolve INSIDE the requested project's own `src/` — an absolute path or one using `..` to point elsewhere is rejected. +- A scoped run is not a smaller version of the full run: see `modulePath` above — rules needing cross-module context are only reliable without `modulePath`. diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java index 270161099..e8ad85ac7 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.IdentityHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -92,7 +93,9 @@ public String getInputSchema() .stringProperty(McpKeys.PROJECT_NAME, "EDT project name to review.", true) //$NON-NLS-1$ .stringProperty(McpKeys.MODULE_PATH, "Optional: narrow the review to a single module, path from src/ " //$NON-NLS-1$ - + "(e.g. 'CommonModules/Calc/Module.bsl'). Omit to review the whole configuration.") //$NON-NLS-1$ + + "(e.g. 'CommonModules/Calc/Module.bsl'); must be a .bsl module. Omit to review " //$NON-NLS-1$ + + "the whole configuration - a scoped run cannot see cross-module context, so " //$NON-NLS-1$ + + "rules like unused-export are only reliable without it.") //$NON-NLS-1$ .enumProperty("severity", //$NON-NLS-1$ "Optional: minimum severity to report (error > warning > information > hint). Omit to report all.", //$NON-NLS-1$ "error", "warning", "information", "hint") //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ @@ -141,6 +144,15 @@ public String execute(Map params) { return ToolResult.error(ProjectContext.notFoundMessage(projectName)).toJson(); } + // A CLOSED project still answers exists() and still has its sources on disk, so without + // this the engine would analyse them and report findings as if the project were open - or + // fail later with a misleading "no src/ folder to review". Named separately from + // not-found because the remedy is different (open it, not check the name). + if (!ctx.isOpen()) + { + return ToolResult.error("Project is closed: " + projectName //$NON-NLS-1$ + + ". Open it in EDT, then retry code_review.").toJson(); //$NON-NLS-1$ + } IProject project = ctx.project(); IFolder srcFolder = project.getFolder("src"); //$NON-NLS-1$ @@ -179,6 +191,20 @@ public String execute(Map params) + "relative to src/ that stays inside this project, e.g. 'CommonModules/Calc/Module.bsl' " //$NON-NLS-1$ + "— not an absolute path or one using '..' to escape src/.").toJson(); //$NON-NLS-1$ } + // The engine reports diagnostics for BSL modules only. Any OTHER existing file under + // src/ (Configuration.mdo, a template, a picture) passes the checks above, gets its + // containing folder analysed, and then matches nothing when findings are filtered to + // this exact path - so the tool would answer "no issues" for a file it could never have + // reviewed. Reject it by name rather than report a false clean. + if (!isBslModule(moduleOsFile.getName())) + { + return ToolResult.error("modulePath '" + modulePath + "' is not a BSL module. " //$NON-NLS-1$ //$NON-NLS-2$ + + "code_review analyses BSL sources (*.bsl); a metadata or template file has no " //$NON-NLS-1$ + + "code-quality diagnostics, so reviewing it would report 'no issues' for a file " //$NON-NLS-1$ + + "that was never checked. Pass a module path such as " //$NON-NLS-1$ + + "'CommonModules/Calc/Module.bsl', or omit modulePath to review the whole " //$NON-NLS-1$ + + "project.").toJson(); //$NON-NLS-1$ + } targetAbsPath = normalize(moduleOsFile.getAbsolutePath()); scopeDir = moduleOsFile.getParentFile(); } @@ -253,7 +279,10 @@ static String render(BslLsReport report, String projectName, String modulePath, { int minRank = severityMin == null || severityMin.isEmpty() ? Integer.MIN_VALUE : rank(Severity.valueOf(severityMin.toUpperCase(Locale.ROOT))); - String ruleNeedle = rule == null ? null : rule.toLowerCase(Locale.ROOT); + // isEmpty() as well as null, matching excludeRule/severityMin above: a client that sends + // "" for an unset optional parameter meant "no filter". Without it, contains("") keeps every + // finding that HAS a code and silently drops the ones whose code the engine omitted. + String ruleNeedle = rule == null || rule.isEmpty() ? null : rule.toLowerCase(Locale.ROOT); String excludeNeedle = excludeRule == null || excludeRule.isEmpty() ? null : excludeRule.toLowerCase(Locale.ROOT); // Module scope FIRST: when modulePath narrows to one file, the engine still analyzed the @@ -290,13 +319,22 @@ static String render(BslLsReport report, String projectName, String modulePath, } filtered.add(f); } + // Relativize ONCE per finding, not once per comparison: modulePathOf normalizes two + // absolute paths, and a comparator key extractor is called O(n log n) times - on a + // whole-project review of a large configuration that is hundreds of thousands of path + // normalizations for a sort of a few thousand rows. + Map modulePaths = new IdentityHashMap<>(); + for (Finding f : filtered) + { + modulePaths.put(f, modulePathOf(srcRoot, f.path())); + } filtered.sort(Comparator.comparingInt((Finding f) -> rank(f.severity())).reversed() - .thenComparing(f -> modulePathOf(srcRoot, f.path())) + .thenComparing(modulePaths::get) .thenComparingInt(Finding::line)); StringBuilder md = new StringBuilder(); String scope = modulePath == null || modulePath.isEmpty() ? projectName : projectName + " / " + modulePath; //$NON-NLS-1$ - md.append("# Code review — ").append(MarkdownUtils.escapeForTable(scope)).append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$ + md.append("# Code review — ").append(scope).append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$ md.append("**").append(scoped.size()).append("** finding(s): ") //$NON-NLS-1$ //$NON-NLS-2$ .append(countBySeverity(scoped, Severity.ERROR)).append(" error, ") //$NON-NLS-1$ @@ -425,6 +463,23 @@ private static String label(Severity s) } } + /** + * Whether {@code fileName} names a BSL module — the only thing the engine produces diagnostics + * for. + *

+ * Package-visible so the rule is pinned by a unit test rather than only by the live e2e: any + * other existing file under {@code src/} would otherwise pass the path checks, get its folder + * analysed, and match nothing when findings are filtered to it — answering "no issues" for a + * file that was never reviewed. + * + * @param fileName the resolved file's name + * @return {@code true} when it is a {@code .bsl} module + */ + static boolean isBslModule(String fileName) + { + return fileName != null && fileName.toLowerCase(Locale.ROOT).endsWith(".bsl"); //$NON-NLS-1$ + } + /** * Relativizes an absolute finding path to the project {@code src} root, yielding the * {@code modulePath} form ({@code CommonModules/Calc/Module.bsl}) that diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsReport.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsReport.java index f97fcf9ae..74f03c526 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsReport.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsReport.java @@ -302,11 +302,20 @@ public static BslLsReport parse(String json) throw new IllegalArgumentException("BSL LS report is not a JSON object"); //$NON-NLS-1$ } JsonObject root = rootEl.getAsJsonObject(); - JsonArray fileInfos = asArray(root, "fileinfos"); //$NON-NLS-1$ - if (fileInfos == null) - { - return new BslLsReport(findings, metrics); - } + // `fileinfos` is REQUIRED, not optional-with-an-empty-default. An engine of the wrong + // version, or an EDT_MCP_BSL_LS_JAR wrapper, can exit 0 and still write a well-formed JSON + // object that is not a report at all ({} or a status/error object). Treating that as "zero + // findings" reports a CLEAN project for a run that never analysed anything - the same + // false-clean this class already refuses for a non-zero exit. Absent or wrongly typed, it + // is a report-format failure and must say so. + if (!root.has("fileinfos") || !root.get("fileinfos").isJsonArray()) //$NON-NLS-1$ //$NON-NLS-2$ + { + throw new IllegalArgumentException("BSL LS report has no 'fileinfos' array - the engine " //$NON-NLS-1$ + + "produced JSON that is not an analysis report (a wrong engine version, or a " //$NON-NLS-1$ + + "wrapper writing its own output). Reporting it as 'no issues found' would hide " //$NON-NLS-1$ + + "that nothing was actually analysed."); //$NON-NLS-1$ + } + JsonArray fileInfos = root.get("fileinfos").getAsJsonArray(); //$NON-NLS-1$ for (JsonElement fiEl : fileInfos) { diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java index 5edaa953c..51c6bb319 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java @@ -15,8 +15,16 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.concurrent.TimeUnit; +import com._1c.g5.v8.dt.common.FileUtil; + +import com.ditrix.edt.mcp.server.Activator; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + /** * Runs the external BSL Language Server engine ({@code bsl-language-server-*-exec.jar}) * as a subprocess in analyze mode and returns its parsed JSON report. This is the @@ -53,7 +61,16 @@ public final class BslLsRunner public static final String RELEASES_URL = "https://github.com/1c-syntax/bsl-language-server/releases"; //$NON-NLS-1$ private static final String REPORT_FILE = "bsl-json.json"; //$NON-NLS-1$ - private static final int DEFAULT_TIMEOUT_SECONDS = 180; + /** + * Subprocess deadline, deliberately UNDER the MCP transport's own ceiling. + *

+ * A longer engine timeout is not a longer answer, it is no answer: the client cuts the call + * first and the caller gets a bare transport error instead of this tool's actionable one (the + * same physics {@code RunYaxunitTestsTool.MAX_TIMEOUT_SECONDS} pins at 45s, for the same + * reason). A configuration too large to analyse inside this window cannot be reviewed whole + * through MCP at all - {@link #narrowingAdvice} says so, and says it in time to be delivered. + */ + private static final int DEFAULT_TIMEOUT_SECONDS = 45; /** * Bound on the engine's captured stdout+stderr (merged via @@ -238,7 +255,34 @@ public static Result run(Request request) { return Result.error(javaNotFoundMessage()); } + String incompatible = incompatibleEngineMessage(jar, request.javaOverride); + if (incompatible != null) + { + // Before refusing, look for a runnable engine in the SAME folder: telling someone to + // "install the 0.28.x line" when they already did - it just lost the newest-wins scan - + // is advice they cannot act on. Only the scanned default folder is reconsidered; an + // explicitly pointed-at jar (override/env) is the caller's deliberate choice. + File compatible = isFile(request.jarOverride) || isFile(fileFromEnv(ENV_JAR)) + ? null : newestRunnableJar(jar.getParentFile()); + if (compatible == null) + { + return Result.error(incompatible); + } + Activator.logWarning("Engine " + jar.getName() + " needs a newer Java than EDT runs; " //$NON-NLS-1$ //$NON-NLS-2$ + + "using " + compatible.getName() + " from the same folder instead."); //$NON-NLS-1$ //$NON-NLS-2$ + jar = compatible; + } + // Also covers the config the engine would DISCOVER on its own. When nothing is passed via + // --configuration the engine searches its working directory - which is the project's own + // workspace root (see execute) - so a .bsl-language-server.json sitting there would be read + // without ever passing through withoutFileWritingKeys, and its traceLog would drop a log + // file into the project from a read-only tool. Resolving it here means it is always the + // SANITIZED copy that reaches the engine. File config = resolveConfig(request.configFile, jar); + if (!isFile(config)) + { + config = discoverableConfig(resolveWorkspaceDir(request)); + } Path outputDir; try @@ -253,7 +297,7 @@ public static Result run(Request request) try { - return execute(java, jar, config, request, outputDir); + return execute(java, jar, withoutFileWritingKeys(config, outputDir), request, outputDir); } finally { @@ -261,6 +305,94 @@ public static Result run(Request request) } } + /** + * The config the engine would find BY ITSELF when {@code --configuration} is omitted, so it can + * be sanitized instead of read behind our back. + *

+ * The engine searches its working directory and then the user's home directory. Both are + * reachable here: the working directory is the project's own workspace root (see + * {@link #execute}), and a {@code traceLog} in EITHER would be written relative to that working + * directory - i.e. INTO the analysed project, from a tool annotated read-only. Naming the file + * explicitly is what lets {@link #withoutFileWritingKeys} strip it first. + * + * @param workspaceDir the directory the engine will run in + * @return the config the engine would otherwise discover, or {@code null} when there is none + */ + private static File discoverableConfig(File workspaceDir) + { + if (workspaceDir != null) + { + File inCwd = new File(workspaceDir, ".bsl-language-server.json"); //$NON-NLS-1$ + if (inCwd.isFile()) + { + return inCwd; + } + } + File inHome = new File(System.getProperty("user.home", ""), ".bsl-language-server.json"); //$NON-NLS-1$ //$NON-NLS-2$ + return inHome.isFile() ? inHome : null; + } + + /** + * Keys in the engine's own configuration that make it WRITE a file. Verified against engine + * 1.0.3: with {@code "traceLog": "bsl-trace.log"} in the project's + * {@code .bsl-language-server.json}, an analyze run creates that file relative to the process + * working directory, i.e. inside the analysed project. + */ + private static final String[] FILE_WRITING_CONFIG_KEYS = {"traceLog"}; //$NON-NLS-1$ + + /** + * Returns a config for the engine with every file-WRITING key removed, copied into + * {@code outputDir} — or {@code config} itself when it has none (the common case, no copy). + *

+ * {@code code_review} is annotated READ-ONLY, and a read-only tool must not create files in the + * project just because the project's config asks the engine to. That is not hypothetical: + * running 1.0.3 with {@code traceLog} set drops the log into the project root. Stripping the + * key keeps the annotation honest without touching the diagnostics configuration the project + * actually cares about. + *

+ * Best-effort by design: a config that cannot be read or parsed is passed through untouched, so + * a malformed file produces the ENGINE's own diagnostics rather than a wrapper-level failure. + * + * @param config the resolved engine config, or {@code null} + * @param outputDir the run's temp directory, where a sanitized copy is written + * @return the config file to pass as {@code --configuration} + */ + static File withoutFileWritingKeys(File config, Path outputDir) + { + if (!isFile(config)) + { + return config; + } + try + { + String text = new String(Files.readAllBytes(config.toPath()), StandardCharsets.UTF_8); + JsonElement rootEl = JsonParser.parseString(text); + if (!rootEl.isJsonObject()) + { + return config; + } + JsonObject root = rootEl.getAsJsonObject(); + boolean stripped = false; + for (String key : FILE_WRITING_CONFIG_KEYS) + { + stripped |= root.remove(key) != null; + } + if (!stripped) + { + return config; + } + Path sanitized = outputDir.resolve("bsl-language-server.json"); //$NON-NLS-1$ + Files.write(sanitized, root.toString().getBytes(StandardCharsets.UTF_8)); + return sanitized.toFile(); + } + catch (IOException | RuntimeException e) + { + Activator.logWarning("Could not strip file-writing keys from " + config //$NON-NLS-1$ + + "; passing it through unchanged: " + e.getMessage()); //$NON-NLS-1$ + return config; + } + } + /** * Builds the engine CLI invocation. Pure/side-effect-free (no process launched), so * it is directly unit-testable. @@ -348,6 +480,10 @@ private static Result execute(File java, File jar, File config, Request request, try { process = pb.start(); + // Close the child's stdin immediately: we never write to it, and an engine build or an + // EDT_MCP_BSL_LS_JAR wrapper script that reads stdin would otherwise block on an open + // empty pipe instead of seeing EOF - burning the whole timeout for nothing. + closeQuietly(process.getOutputStream()); } catch (IOException e) { @@ -374,8 +510,11 @@ private static Result execute(File java, File jar, File config, Request request, { process.destroyForcibly(); join(drain); + // No "raise the timeout" advice: code_review exposes no timeout parameter and there is + // no env/preference override, so telling the caller to raise it would send them after + // a knob that does not exist. Name only what they can actually do. return Result.error("BSL Language Server timed out after " + request.timeoutSeconds //$NON-NLS-1$ - + "s. Narrow the scope or raise the timeout."); //$NON-NLS-1$ + + "s." + narrowingAdvice(request)); //$NON-NLS-1$ } join(drain); @@ -389,13 +528,13 @@ private static Result execute(File java, File jar, File config, Request request, { return Result.error("BSL Language Server exited with status " + exit + " (a clean analyze run " //$NON-NLS-1$ //$NON-NLS-2$ + "exits 0 even when it reports diagnostics, so this is an operational failure, not " //$NON-NLS-1$ - + "findings). Engine output: " + tail(captured.toString())); //$NON-NLS-1$ + + "findings). Engine output: " + tail(snapshot(captured))); //$NON-NLS-1$ } Path reportPath = outputDir.resolve(REPORT_FILE); if (!Files.isRegularFile(reportPath)) { return Result.error("BSL Language Server produced no JSON report despite exiting 0. " //$NON-NLS-1$ - + "Engine output: " + tail(captured.toString())); //$NON-NLS-1$ + + "Engine output: " + tail(snapshot(captured))); //$NON-NLS-1$ } // Checked BEFORE any read: a pathologically large report must not be pulled fully into @@ -414,8 +553,7 @@ private static Result execute(File java, File jar, File config, Request request, if (reportSize > MAX_REPORT_BYTES) { return Result.error("BSL Language Server report is " + reportSize + " bytes, over the " //$NON-NLS-1$ //$NON-NLS-2$ - + MAX_REPORT_BYTES + "-byte limit; not read into memory. Narrow the scope (pass a " //$NON-NLS-1$ - + "modulePath) and re-run."); //$NON-NLS-1$ + + MAX_REPORT_BYTES + "-byte limit; not read into memory." + narrowingAdvice(request)); //$NON-NLS-1$ } String json; @@ -678,6 +816,148 @@ private static boolean isNumericIdentifier(String s) return true; } + /** + * Refuses a jar/runtime pair that provably cannot launch, BEFORE spawning the process — or + * {@code null} when the pair is fine (or cannot be judged). + *

+ * The engine's {@code 1.x} line is compiled for Java 21; {@code 0.28.x} runs on Java 17. When + * both jars sit in the default folder, {@link #scanForExecJar} picks the NEWEST — correct on + * its own terms, but on an EDT running Java 17 that choice ends in an + * {@code UnsupportedClassVersionError} from the child, surfacing as an opaque "no report + * produced" even though a runnable engine is installed right next to it. + *

+ * Judged only when the runtime is the JVM hosting EDT (no {@code javaOverride}, no + * {@link #ENV_JAVA}), because that is the one case whose version is known for free from + * {@code java.specification.version}. An explicitly pointed-at Java is the caller's deliberate + * choice and would cost a probe subprocess to inspect, so it is left alone — a wrong one still + * fails, just with the engine's own message. + * + * @param jar the resolved engine jar + * @param javaOverride the explicit Java from the request, or {@code null} + * @return an actionable error message, or {@code null} when there is no known incompatibility + */ + static String incompatibleEngineMessage(File jar, File javaOverride) + { + if (jar == null || isFile(javaOverride) || isFile(fileFromEnv(ENV_JAVA))) + { + return null; + } + return incompatibleEngineMessage(jar.getName(), hostJavaMajor()); + } + + /** + * The remediation half of the "too big / too slow" errors, phrased for the scope the caller + * ACTUALLY used. + *

+ * Telling someone to "pass a modulePath" when they already passed one is dead advice — and this + * runner is the only place that knows which it was: a scoped run narrows {@code srcDir} below + * {@code workspaceDir}, a whole-project run leaves them equal. + * + * @param request the run being reported on + * @return a sentence beginning with a space, ready to append to the error + */ + private static String narrowingAdvice(Request request) + { + File workspace = resolveWorkspaceDir(request); + boolean alreadyScoped = workspace != null && request.srcDir != null + && !workspace.getAbsolutePath().equals(request.srcDir.getAbsolutePath()); + if (alreadyScoped) + { + return " This run was already scoped to one module, so there is nothing left to narrow: " //$NON-NLS-1$ + + "the module itself is too large for the engine to handle inside EDT. Split it, or " //$NON-NLS-1$ + + "run the engine outside EDT for this one."; //$NON-NLS-1$ + } + return " Review a single module with modulePath instead of the whole project, or run the " //$NON-NLS-1$ + + "engine outside EDT for a configuration this large."; //$NON-NLS-1$ + } + + /** + * The newest jar in {@code dir} that the host Java can actually launch, or {@code null} when + * there is none (or the folder holds nothing else). + *

+ * The fallback for the case {@link #incompatibleEngineMessage} detects: both engine lines + * installed side by side and the newest-wins scan picking the one this runtime cannot start. + * Rather than refuse a setup that DOES contain a runnable engine, drop back to the newest + * runnable one. + * + * @param dir the folder the incompatible jar was scanned from + * @return a launchable jar, or {@code null} + */ + private static File newestRunnableJar(File dir) + { + if (dir == null || !dir.isDirectory()) + { + return null; + } + File[] jars = dir.listFiles((d, name) -> name.startsWith("bsl-language-server") //$NON-NLS-1$ + && name.endsWith("-exec.jar")); //$NON-NLS-1$ + if (jars == null) + { + return null; + } + int runtime = hostJavaMajor(); + File best = null; + for (File candidate : jars) + { + if (incompatibleEngineMessage(candidate.getName(), runtime) != null) + { + continue; + } + if (best == null || compareJarVersions(candidate.getName(), best.getName()) > 0) + { + best = candidate; + } + } + return best; + } + + /** + * The pure half of {@link #incompatibleEngineMessage(File, File)}: decides on a jar NAME and a + * runtime major version alone, so the rule is unit-testable without depending on whichever Java + * happens to be running the tests. + * + * @param jarName the engine jar's file name + * @param runtimeMajor the major version of the Java that would launch it, or a non-positive + * value when it is unknown + * @return an actionable error message, or {@code null} when there is no known incompatibility + */ + static String incompatibleEngineMessage(String jarName, int runtimeMajor) + { + String version = extractVersion(jarName); + if (version == null || !version.startsWith("1.")) //$NON-NLS-1$ + { + return null; + } + if (runtimeMajor <= 0 || runtimeMajor >= 21) + { + return null; + } + return "The BSL Language Server engine " + jarName + " needs Java 21, but EDT runs on " //$NON-NLS-1$ //$NON-NLS-2$ + + "Java " + runtimeMajor + " and no other Java was pointed at. Launching it would fail " //$NON-NLS-1$ //$NON-NLS-2$ + + "with an UnsupportedClassVersionError. Either set " + ENV_JAVA + " to a Java 21+ " //$NON-NLS-1$ //$NON-NLS-2$ + + "executable, or install the 0.28.x engine line (which runs on Java 17) and point " //$NON-NLS-1$ + + ENV_JAR + " at it: " + RELEASES_URL; //$NON-NLS-1$ + } + + /** The major version of the JVM hosting EDT, or {@code -1} when it cannot be determined. */ + private static int hostJavaMajor() + { + String spec = System.getProperty("java.specification.version", ""); //$NON-NLS-1$ //$NON-NLS-2$ + // "17", "21" on modern JDKs; "1.8" on 8 and older. + if (spec.startsWith("1.")) //$NON-NLS-1$ + { + spec = spec.substring(2); + } + try + { + return Integer.parseInt(spec.trim()); + } + catch (NumberFormatException e) + { + return -1; + } + } + /** * Resolves the Java launcher: explicit override, then {@link #ENV_JAVA}, then the * JRE running EDT ({@code java.home}). Returns {@code null} only if none resolves to @@ -817,6 +1097,51 @@ private static String tail(String s) return "…" + trimmed.substring(trimmed.length() - max); //$NON-NLS-1$ } + /** + * Reads {@code sink} under the SAME lock {@link #drainAsync} writes it with. + *

+ * {@code join(drain)} is not a guarantee the drain has stopped: it swallows an interrupt and + * gives up after its own cap, so the daemon thread can still be inside + * {@code append}/{@code delete} when the caller wants the text. StringBuilder is not + * thread-safe, and an unsynchronised read can observe a shrunk length against a reallocated + * buffer — throwing out of a method this class documents as never throwing for an operational + * problem (and past {@code ToolResult.error}, CLAUDE.md don't #8). + * + * @param sink the drain's buffer + * @return a stable copy of its current contents + */ + private static String snapshot(StringBuilder sink) + { + synchronized (sink) + { + return sink.toString(); + } + } + + /** Closes {@code closeable}, ignoring failure — used for the child's unused stdin pipe. */ + private static void closeQuietly(java.io.Closeable closeable) + { + try + { + closeable.close(); + } + catch (IOException ignored) + { + // best effort + } + } + + /** + * Removes the run's temp directory, tolerating the Windows case where the just-exited engine + * still holds a handle on it. + *

+ * Delegates to the platform helper the rest of this plugin already uses for exactly this + * problem ({@code DeleteInfobaseTool}): it retries a few times instead of giving up on the + * first failure. A single-shot delete loses the race often enough on Windows that every + * invocation could orphan a temp tree holding a multi-MB report. + * + * @param dir the temp directory to remove; {@code null} is ignored + */ private static void deleteQuietly(Path dir) { if (dir == null) @@ -825,22 +1150,11 @@ private static void deleteQuietly(Path dir) } try { - Files.walk(dir) - .sorted((a, b) -> b.getNameCount() - a.getNameCount()) - .forEach(p -> { - try - { - Files.deleteIfExists(p); - } - catch (IOException ignored) - { - // best effort - } - }); + FileUtil.deleteRecursivelyWithRetries(dir); } - catch (IOException ignored) + catch (IOException | RuntimeException ignored) { - // best effort + // best effort - a leftover temp dir is not worth failing a completed review over } } @@ -861,6 +1175,6 @@ private static boolean isFile(File f) private static boolean isWindows() { - return System.getProperty("os.name", "").toLowerCase().contains("win"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ } } diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java index 4cfa3941a..6fcec5f81 100644 --- a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewToolTest.java @@ -118,7 +118,12 @@ public void testSchemaDeclaresParameters() assertTrue(schema.contains("\"modulePath\"")); //$NON-NLS-1$ assertTrue(schema.contains("\"severity\"")); //$NON-NLS-1$ assertTrue(schema.contains("\"rule\"")); //$NON-NLS-1$ + assertTrue(schema.contains("\"excludeRule\"")); //$NON-NLS-1$ assertTrue(schema.contains("\"limit\"")); //$NON-NLS-1$ + // The .bsl-only rule is enforced in execute(); the schema must SAY so, or a client that + // never fetches the guide meets it as a surprise error (schema and guide state one contract). + assertTrue("the schema must state the .bsl-only constraint: " + schema, //$NON-NLS-1$ + schema.contains(".bsl module")); //$NON-NLS-1$ } @Test @@ -351,4 +356,25 @@ public void testIsWithinSrcRejectsNullArguments() assertFalse(CodeReviewTool.isWithinSrc(new File("C:/proj/src"), null)); //$NON-NLS-1$ assertFalse(CodeReviewTool.isWithinSrc(null, null)); } + + // ---- isBslModule: the engine only diagnoses .bsl, so anything else must be refused ---- + + @Test + public void testBslModulesAreAccepted() + { + assertTrue(CodeReviewTool.isBslModule("Module.bsl")); //$NON-NLS-1$ + assertTrue("the check must not be case-sensitive", CodeReviewTool.isBslModule("MODULE.BSL")); //$NON-NLS-1$ //$NON-NLS-2$ + } + + @Test + public void testNonBslFilesAreRejected() + { + // The false-clean this guard exists for: these all EXIST under src/, so every earlier path + // check passes, and only the extension separates "reviewed and clean" from "never reviewed". + assertFalse(CodeReviewTool.isBslModule("Configuration.mdo")); //$NON-NLS-1$ + assertFalse(CodeReviewTool.isBslModule("Template.xml")); //$NON-NLS-1$ + assertFalse(CodeReviewTool.isBslModule("Form.form")); //$NON-NLS-1$ + assertFalse(CodeReviewTool.isBslModule("bsl")); //$NON-NLS-1$ + assertFalse(CodeReviewTool.isBslModule(null)); + } } diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsReportTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsReportTest.java index c96e3464d..e5ae7fc02 100644 --- a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsReportTest.java +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsReportTest.java @@ -143,10 +143,38 @@ public void testEmptyReportIsEmptyNotError() } @Test - public void testMissingFileinfosKeyTolerated() + public void testMissingFileinfosKeyIsAReportFormatError() { - BslLsReport report = BslLsReport.parse("{}"); - assertEquals(0, report.total()); + // Deliberately the OPPOSITE of what this test used to assert. Tolerating a report with no + // `fileinfos` turned "the engine wrote JSON that is not an analysis report" - a wrong + // engine version, or a wrapper on EDT_MCP_BSL_LS_JAR writing its own status object - into + // "0 findings", i.e. a CLEAN project for a run that analysed nothing. An empty ARRAY is + // still a legitimate clean result (see testEmptyReportIsEmptyNotError); an absent key is not. + try + { + BslLsReport.parse("{}"); + fail("a report without 'fileinfos' must not be reported as a clean project"); + } + catch (IllegalArgumentException expected) + { + assertTrue("the error must name the missing field: " + expected.getMessage(), + expected.getMessage().contains("fileinfos")); + } + } + + @Test + public void testFileinfosOfTheWrongTypeIsAReportFormatError() + { + try + { + BslLsReport.parse("{\"fileinfos\":{}}"); + fail("a non-array 'fileinfos' must not be reported as a clean project"); + } + catch (IllegalArgumentException expected) + { + assertTrue("the error must name the offending field: " + expected.getMessage(), + expected.getMessage().contains("fileinfos")); + } } @Test diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java index 75077a09b..b5ff28558 100644 --- a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java @@ -8,6 +8,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -15,10 +16,12 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; import java.util.List; +import java.util.stream.Stream; import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; @@ -64,8 +67,12 @@ public void tearDown() throws IOException { if (root != null && Files.exists(root)) { - Files.walk(root) - .sorted(Comparator.reverseOrder()) + // try-with-resources for the same reason BslLsRunner.deleteQuietly uses it: an + // unclosed Files.walk holds a directory handle, one per @Test adds up, and on Windows + // the retained handle also makes the delete below fail - orphaning every fixture tree. + try (Stream walk = Files.walk(root)) + { + walk.sorted(Comparator.reverseOrder()) .forEach(p -> { try { @@ -76,6 +83,7 @@ public void tearDown() throws IOException // best effort } }); + } } } @@ -663,4 +671,88 @@ private File newFolder(String name) assertTrue(f.mkdirs()); return f; } + + // ---- engine/runtime compatibility: fail with a reason instead of an opaque child crash ---- + + @Test + public void testOneXEngineOnJava17IsRefusedWithAnActionableMessage() + { + String msg = BslLsRunner.incompatibleEngineMessage("bsl-language-server-1.0.3-exec.jar", 17); //$NON-NLS-1$ + + assertNotNull("a 1.x engine cannot start on Java 17 - refuse before launching", msg); //$NON-NLS-1$ + assertTrue("must name the engine: " + msg, msg.contains("bsl-language-server-1.0.3-exec.jar")); //$NON-NLS-1$ //$NON-NLS-2$ + assertTrue("must name the runtime it found: " + msg, msg.contains("Java 17")); //$NON-NLS-1$ //$NON-NLS-2$ + assertTrue("must name the escape hatch: " + msg, msg.contains(BslLsRunner.ENV_JAVA)); //$NON-NLS-1$ + } + + @Test + public void testOneXEngineOnJava21IsAccepted() + { + assertNull(BslLsRunner.incompatibleEngineMessage("bsl-language-server-1.0.3-exec.jar", 21)); //$NON-NLS-1$ + } + + @Test + public void testLegacyEngineLineIsAcceptedOnJava17() + { + // 0.28.x is exactly the line that DOES run on 17 - refusing it would be the opposite error. + assertNull(BslLsRunner.incompatibleEngineMessage("bsl-language-server-0.28.1-exec.jar", 17)); //$NON-NLS-1$ + } + + @Test + public void testUnknownRuntimeVersionIsNotRefused() + { + // Cannot be judged -> let the engine speak for itself rather than block a workable setup. + assertNull(BslLsRunner.incompatibleEngineMessage("bsl-language-server-1.0.3-exec.jar", -1)); //$NON-NLS-1$ + } + + // ---- config sanitising: a read-only tool must not let the engine write into the project ---- + + @Test + public void testTraceLogIsStrippedFromTheConfigHandedToTheEngine() throws Exception + { + // Verified against engine 1.0.3: with "traceLog" set, an analyze run creates that file + // relative to the process working directory - i.e. inside the analysed project, from a tool + // annotated READ-ONLY. The key is removed; everything else is passed through untouched. + File dir = newFolder("cfg-tracelog"); //$NON-NLS-1$ + File config = new File(dir, ".bsl-language-server.json"); //$NON-NLS-1$ + Files.write(config.toPath(), + "{\"traceLog\":\"bsl-trace.log\",\"diagnostics\":{\"parameters\":{\"Typo\":false}}}" //$NON-NLS-1$ + .getBytes(StandardCharsets.UTF_8)); + File outputDir = newFolder("cfg-tracelog-out"); //$NON-NLS-1$ + + File sanitized = BslLsRunner.withoutFileWritingKeys(config, outputDir.toPath()); + + assertNotEquals("a config carrying traceLog must not be handed to the engine as-is", //$NON-NLS-1$ + config.getAbsolutePath(), sanitized.getAbsolutePath()); + String text = new String(Files.readAllBytes(sanitized.toPath()), StandardCharsets.UTF_8); + assertFalse("traceLog must be gone: " + text, text.contains("traceLog")); //$NON-NLS-1$ //$NON-NLS-2$ + assertTrue("the project's own diagnostics config must survive: " + text, //$NON-NLS-1$ + text.contains("Typo")); //$NON-NLS-1$ + } + + @Test + public void testConfigWithoutFileWritingKeysIsPassedThroughUnchanged() throws Exception + { + // The common case: no copy, no temp write, the project's file used directly. + File dir = newFolder("cfg-plain"); //$NON-NLS-1$ + File config = new File(dir, ".bsl-language-server.json"); //$NON-NLS-1$ + Files.write(config.toPath(), "{\"diagnostics\":{\"parameters\":{}}}".getBytes(StandardCharsets.UTF_8)); //$NON-NLS-1$ + File outputDir = newFolder("cfg-plain-out"); //$NON-NLS-1$ + + assertEquals(config.getAbsolutePath(), + BslLsRunner.withoutFileWritingKeys(config, outputDir.toPath()).getAbsolutePath()); + } + + @Test + public void testMalformedConfigIsPassedThroughSoTheEngineReportsIt() throws Exception + { + File dir = newFolder("cfg-broken"); //$NON-NLS-1$ + File config = new File(dir, ".bsl-language-server.json"); //$NON-NLS-1$ + Files.write(config.toPath(), "{ this is not json".getBytes(StandardCharsets.UTF_8)); //$NON-NLS-1$ + File outputDir = newFolder("cfg-broken-out"); //$NON-NLS-1$ + + assertEquals("a malformed config must reach the engine, which diagnoses it better than we can", //$NON-NLS-1$ + config.getAbsolutePath(), + BslLsRunner.withoutFileWritingKeys(config, outputDir.toPath()).getAbsolutePath()); + } } diff --git a/tests/e2e/tools/test_code_review.py b/tests/e2e/tools/test_code_review.py index 8fc5fe4a8..24cf2cc1c 100644 --- a/tests/e2e/tools/test_code_review.py +++ b/tests/e2e/tools/test_code_review.py @@ -25,6 +25,7 @@ from harness import ( call, assert_ok, assert_contains, assert_not_contains, assert_error, assert_error_quality, assert_no_diff, e2e_test, PROJECT, E2ESkip, _fail, + split_markdown_row, ) # Substrings that identify the actionable "engine not installed" error @@ -160,3 +161,60 @@ def test_nonexistent_module_is_rejected(): assert_contains(err, "Module not found", "the error must state the module was not found") assert_contains(err, bad_module, "the error must name the bad module path") assert_no_diff("a rejected call must not touch the project on disk") + + +def _rule_cells(text): + """The Rule column of every finding row, via the shared escape-aware parser. + + Deliberately NOT a substring search over the whole response: the legend under the table + names MagicNumber as an EXAMPLE of a mechanically fixable rule, so a naive `"MagicNumber" + in text` reports a leftover row that does not exist. Only cells count as findings. + """ + rules = [] + for line in (text or "").splitlines(): + cells = split_markdown_row(line) + if len(cells) < 3 or cells[0].lower() in ("severity", "---"): + continue + rules.extend(c.strip("`") for c in cells if "MagicNumber" in c) + return rules + + +@e2e_test(tool="code_review", kind="read") +def test_exclude_rule_drops_only_the_named_rule(): + """excludeRule is the parameter the description leans on hardest (drop rules you already + get from get_project_errors), yet it was only covered headlessly against canned JSON. + This pins the WIRING: execute() reads it and render() applies it. Both filters are asked + of the same live scan, so the assertion is a real comparison rather than a self-check - + the excluded rule must vanish from the rows while the rest of the table survives.""" + base = call("code_review", {"projectName": PROJECT}) + assert_ok(base, "unfiltered project review") + if not _rule_cells(base.text): + raise E2ESkip("the fixture currently reports no MagicNumber finding to exclude") + + filtered = call("code_review", {"projectName": PROJECT, "excludeRule": "MagicNumber"}) + assert_ok(filtered, "review with excludeRule=MagicNumber") + leftover = _rule_cells(filtered.text) + if leftover: + raise AssertionError( + "excludeRule=MagicNumber must drop every MagicNumber row, but %d row(s) remain: %r " + "- the parameter is not reaching the filter" % (len(leftover), leftover[:3])) + # The filter must be a scalpel, not a mute button: other findings still have to come through, + # or "no MagicNumber rows" would also pass for a tool that returned nothing at all. + if not (filtered.text or "").strip(): + raise AssertionError("excludeRule emptied the whole report instead of dropping one rule") + assert_no_diff("a read-only review must not touch the project on disk") + + +@e2e_test(tool="code_review", kind="read") +def test_non_bsl_module_path_is_rejected(): + """A modulePath naming an EXISTING file that is not a BSL module must be refused, not + reviewed. The engine reports diagnostics for .bsl sources only, so scoping to (say) + Configuration.mdo would analyse its folder, match nothing when the findings are filtered + to that exact path, and answer 'no issues' for a file that was never checked - a false + clean, which is worse than an error.""" + not_a_module = "Configuration/Configuration.mdo" + r = call("code_review", {"projectName": PROJECT, "modulePath": not_a_module}) + err = assert_error(r, "modulePath pointing at a non-BSL file") + assert_contains(err, not_a_module, "the error must name the offending path") + assert_contains(err, ".bsl", "the error must say what IS accepted") + assert_no_diff("a rejected call must not touch the project on disk") diff --git a/tests/e2e/tools_list.golden.json b/tests/e2e/tools_list.golden.json index 162618b88..148bc60e2 100644 --- a/tests/e2e/tools_list.golden.json +++ b/tests/e2e/tools_list.golden.json @@ -271,27 +271,22 @@ "inputSchema": { "properties": { "excludeRule": { - "description": "Optional: drop diagnostics whose rule id contains this substring — e.g. to exclude rules you already get from get_project_errors and avoid double-reporting the same issue.", "type": "string" }, "limit": { - "description": "Max findings; default 100, max 1000 (optional).", "type": "integer" }, "modulePath": { - "description": "Optional: narrow the review to a single module, path from src/ (e.g. 'CommonModules/Calc/Module.bsl'). Omit to review the whole configuration.", + "description": "Optional: narrow the review to a single module, path from src/ (e.g. 'CommonModules/Calc/Module.bsl'); must be a .bsl module. Omit to review the whole configuration - a scoped run cannot see cross-module context, so rules like unused-export are only reliable without it.", "type": "string" }, "projectName": { - "description": "EDT project name to review.", "type": "string" }, "rule": { - "description": "Optional: report only diagnostics whose rule id contains this substring (e.g. 'Magic', 'Complexity').", "type": "string" }, "severity": { - "description": "Optional: minimum severity to report (error > warning > information > hint). Omit to report all.", "enum": [ "error", "warning", @@ -2812,6 +2807,56 @@ "type": "object" } }, + { + "annotations": { + "destructiveHint": true, + "openWorldHint": true, + "readOnlyHint": false + }, + "description": "Run a git command in a project's repository through the real git CLI, sent as a shell-style string. Only a whitelisted set of subcommands runs, and the write-capable ones (commit, push, checkout, stash) change the repository. DISABLED by default: enable it in Preferences -> MCP Server -> Tools; enable_toolset does not turn it on. Parameters, the whitelist and examples: get_tool_guide('git').", + "inputSchema": { + "properties": { + "command": { + "type": "string" + }, + "projectName": { + "type": "string" + } + }, + "required": [ + "projectName", + "command" + ], + "type": "object" + }, + "name": "git", + "outputSchema": { + "properties": { + "command": { + "type": "string" + }, + "error": { + "type": "string" + }, + "exitCode": { + "type": "integer" + }, + "output": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "success" + ], + "type": "object" + } + }, { "annotations": { "destructiveHint": false, From 1d46047fa3d98cb7deca7252b2c68e5e61cbf36a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B0=D1=80=D0=B0=D1=81=D0=BE=D0=B2=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 18 Aug 2026 13:28:56 +0300 Subject: [PATCH 7/8] =?UTF-8?q?code=5Freview:=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=8B=20=D0=B4=D0=B2=D0=B0=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20CI=20=D0=B8=20=D1=81=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=83=D1=82?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI упал на двух шардах, и оба раза по моей вине — master при этом полностью зелёный (1035 e2e без падений), так что унаследованным это не было. 1. Тест на excludeRule звал call() напрямую вместо _run_or_skip. На CI движок BSL LS не ставится, тул честно возвращает свою «engine not found», и все остальные engine-зависимые тесты в этом файле её скипают — а мой падал. Ровно то, что описано в шапке файла; я мимо неё прошёл. 2. В golden попал тул git. Он выключен по умолчанию (DEFAULT_DISABLED_TOOLS = "git,ask_workmate"), но на моём стенде был включён, и перегенерация записала его в снимок. На CI с заводскими настройками его нет — снимок не сходился. Стенд приведён к заводским отключениям, снимок перегенерирован: теперь он отличается от master ровно на code_review, как и должно быть. Замечание бота (сравнение путей): scoped-прогон фильтровал находки по строковому равенству путей. На регистронезависимой файловой системе (Windows) вызов с 'commonmodules/calc/module.bsl' проходит все проверки, но движок сообщает путь в дисковом регистре — и все находки отсеивались, а модуль объявлялся чистым. Ложная чистота — худший ответ, который этот тул может дать. Теперь идентичность решает файловая система (Files.isSameFile), с откатом на регистронезависимое сравнение, когда файл не прощупывается. Проверено живьём: тот же модуль в нижнем регистре теперь даёт те же 2 находки, что и в каноническом. Закреплено e2e-тестом — нужна настоящая ФС, поэтому не юнит. Проверено: сборка 5344, e2e code_review 11/11, golden сходится, фикстура чистая. --- .../mcp/server/tools/impl/CodeReviewTool.java | 46 ++++++++++++++++- tests/e2e/tools/test_code_review.py | 34 +++++++++++-- tests/e2e/tools_list.golden.json | 50 ------------------- 3 files changed, 76 insertions(+), 54 deletions(-) diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java index e8ad85ac7..58c6cc218 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java @@ -7,6 +7,8 @@ package com.ditrix.edt.mcp.server.tools.impl; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; @@ -295,7 +297,7 @@ static String render(BslLsReport report, String projectName, String modulePath, List scoped = new ArrayList<>(); for (Finding f : report.findings()) { - if (targetAbsPath != null && !targetAbsPath.equals(normalize(f.path()))) + if (targetAbsPath != null && !isSamePath(targetAbsPath, f.path())) { continue; } @@ -508,6 +510,48 @@ private static String modulePathOf(File srcRoot, String absPath) return absPath; } + /** + * Whether two absolute paths denote the SAME file, asking the filesystem rather than comparing + * text. + *

+ * The scoped review filters the engine's findings down to the requested module by path, and a + * plain string compare gets that wrong on a case-insensitive filesystem (Windows): a caller may + * legitimately pass {@code commonmodules/calc/module.bsl} while the engine reports the on-disk + * casing, and every finding would then be filtered out - reporting the module CLEAN when it is + * not. That false clean is the worst answer this tool can give, so identity is decided by + * {@link Files#isSameFile} where both paths exist. + *

+ * Falls back to a case-insensitive text compare when the files cannot be probed (one of them + * gone, or an IO error): still better than an exact compare, and never throws out of a review + * that otherwise succeeded. + * + * @param targetAbsPath the normalized path of the module the caller scoped to + * @param findingPath the path the engine reported for a finding (may be {@code null}) + * @return {@code true} when both denote the same module file + */ + private static boolean isSamePath(String targetAbsPath, String findingPath) + { + String normalized = normalize(findingPath); + if (normalized == null) + { + return false; + } + try + { + Path a = Paths.get(targetAbsPath); + Path b = Paths.get(normalized); + if (Files.exists(a) && Files.exists(b)) + { + return Files.isSameFile(a, b); + } + } + catch (IOException | RuntimeException e) + { + // fall through to the textual comparison below + } + return targetAbsPath.equalsIgnoreCase(normalized); + } + private static String normalize(String path) { if (path == null) diff --git a/tests/e2e/tools/test_code_review.py b/tests/e2e/tools/test_code_review.py index 24cf2cc1c..4a59d79c8 100644 --- a/tests/e2e/tools/test_code_review.py +++ b/tests/e2e/tools/test_code_review.py @@ -185,13 +185,17 @@ def test_exclude_rule_drops_only_the_named_rule(): get from get_project_errors), yet it was only covered headlessly against canned JSON. This pins the WIRING: execute() reads it and render() applies it. Both filters are asked of the same live scan, so the assertion is a real comparison rather than a self-check - - the excluded rule must vanish from the rows while the rest of the table survives.""" - base = call("code_review", {"projectName": PROJECT}) + the excluded rule must vanish from the rows while the rest of the table survives. + + Engine-gated like every other happy path here: _run_or_skip turns the actionable + "engine not installed" answer into a SKIP, which is what CI (no jar) must get.""" + base = _run_or_skip({"projectName": PROJECT}, "unfiltered project review") assert_ok(base, "unfiltered project review") if not _rule_cells(base.text): raise E2ESkip("the fixture currently reports no MagicNumber finding to exclude") - filtered = call("code_review", {"projectName": PROJECT, "excludeRule": "MagicNumber"}) + filtered = _run_or_skip({"projectName": PROJECT, "excludeRule": "MagicNumber"}, + "review with excludeRule=MagicNumber") assert_ok(filtered, "review with excludeRule=MagicNumber") leftover = _rule_cells(filtered.text) if leftover: @@ -205,6 +209,30 @@ def test_exclude_rule_drops_only_the_named_rule(): assert_no_diff("a read-only review must not touch the project on disk") +@e2e_test(tool="code_review", kind="read") +def test_module_path_matching_is_case_insensitive_on_the_filesystem(): + """A modulePath whose casing differs from the on-disk name must still scope correctly. + + On a case-insensitive filesystem (Windows) the workspace happily resolves + 'commonmodules/calc/module.bsl', but the engine reports the on-disk casing - so a + case-SENSITIVE comparison of the two filtered every finding out and answered "module is + clean". A false clean is the worst answer this tool can give, which is why identity is + decided by the filesystem (Files.isSameFile) rather than by string equality. + + Needs a real filesystem, so it lives here rather than in a unit test.""" + exact = _run_or_skip({"projectName": PROJECT, "modulePath": CALC_MODULE}, "module review, exact casing") + assert_ok(exact, "module review with the canonical casing") + assert_contains(exact.text, "MagicNumber", "the canonical casing must report the module's finding") + + lowered = _run_or_skip({"projectName": PROJECT, "modulePath": CALC_MODULE.lower()}, + "module review, lowercased path") + assert_ok(lowered, "module review with a lowercased path") + assert_contains(lowered.text, "MagicNumber", + "the same module addressed in a different case must report the same finding, " + "not come back empty") + assert_no_diff("a read-only review must not touch the project on disk") + + @e2e_test(tool="code_review", kind="read") def test_non_bsl_module_path_is_rejected(): """A modulePath naming an EXISTING file that is not a BSL module must be refused, not diff --git a/tests/e2e/tools_list.golden.json b/tests/e2e/tools_list.golden.json index 148bc60e2..7b7d72711 100644 --- a/tests/e2e/tools_list.golden.json +++ b/tests/e2e/tools_list.golden.json @@ -2807,56 +2807,6 @@ "type": "object" } }, - { - "annotations": { - "destructiveHint": true, - "openWorldHint": true, - "readOnlyHint": false - }, - "description": "Run a git command in a project's repository through the real git CLI, sent as a shell-style string. Only a whitelisted set of subcommands runs, and the write-capable ones (commit, push, checkout, stash) change the repository. DISABLED by default: enable it in Preferences -> MCP Server -> Tools; enable_toolset does not turn it on. Parameters, the whitelist and examples: get_tool_guide('git').", - "inputSchema": { - "properties": { - "command": { - "type": "string" - }, - "projectName": { - "type": "string" - } - }, - "required": [ - "projectName", - "command" - ], - "type": "object" - }, - "name": "git", - "outputSchema": { - "properties": { - "command": { - "type": "string" - }, - "error": { - "type": "string" - }, - "exitCode": { - "type": "integer" - }, - "output": { - "type": "string" - }, - "success": { - "type": "boolean" - }, - "truncated": { - "type": "boolean" - } - }, - "required": [ - "success" - ], - "type": "object" - } - }, { "annotations": { "destructiveHint": false, From a785e48f77bac408acaaf71dc7ff33a8c7314cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B0=D1=80=D0=B0=D1=81=D0=BE=D0=B2=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 18 Aug 2026 14:19:57 +0300 Subject: [PATCH 8/8] =?UTF-8?q?code=5Freview:=20=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=20=D1=81=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BE=D0=BC=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20=D1=82=D0=B8=D1=85=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=85=D0=BE=D0=B4=D0=B0;=20=D0=B4=D0=B2=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BE=D1=82=D0=BA=D0=B0=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбор четырёх замечаний бота. Одно починено как следует, одно оказалось самообманом и откачено, два откачены после того, как самопроверка показала: мои же правки создали больше проблем, чем закрыли. ОСТАВЛЕНО И ДОВЕДЕНО — предел на размер конфига движка. Пункт 3 нашего чек-листа: ограничивать читаемое ДО разбора. Чтение конфига появилось в прошлом заходе (вырезание traceLog), а предела к нему я не применил. Важнее самого предела оказался вопрос, что делать при его превышении: первая версия «пропускала файл как есть», то есть возвращала ровно ту дыру, ради которой вырезание и делалось — движок писал бы traceLog в проект из тула с пометкой read-only. Теперь отказ с внятным объяснением. Чтение ограниченное (readAtMost), а не «сначала размер, потом readAllBytes»: файл может вырасти между проверками, и тогда предел не ограничивает ничего. Заодно тот же вердикт распространён на нечитаемый конфиг: раньше он «пропускался, чтобы движок сам его продиагностировал», но движок, читающий его напрямую, — это и есть незакрытый traceLog. Тест на прежнее поведение заменён. ОТКАЧЕНО — toRealPath в проверке границы src/. Бот просил резолвить симлинки. Самопроверка показала, что правка не закрывает угрозу и ломает рабочий случай: прогон по всему проекту всё равно обходит src/ и читает связанный файл, так что «чтение кода снаружи проекта» остаётся открытым, — зато модуль, законно прилинкованный в src/, перестаёт адресоваться через modulePath, хотя project-wide он виден. Регресс ради незакрытой угрозы. Проверка существует для caller-supplied escape (пункт 4 чек-листа), с чем normalize + startsWith справляется; следование симлинкам — отдельный вопрос уровня srcDir. ОТКАЧЕНО — ожидание процесса после destroyForcibly. Опасение верное, реализация — нет. Ожидание до 10 с добавлялось ПОВЕРХ 45-секундного предела, который выбран так, чтобы уложиться под потолок транспорта: ответ уезжал к ~57 с, и осмысленная ошибка снова не доходила. Плюс на пути прерывания флаг выставлялся ДО ожидания, из-за чего waitFor выбрасывал исключение сразу и ожидания не было вовсе. И главное: в GitTool уже есть killTree/awaitExitQuietly, решающие это правильно (включая внуков процесса), но они private — переиспользовать их можно только вынеся в utils/, а это сквозная правка в файле из master, не для этого PR. Осиротевший временный каталог при этом смягчён: удаление уже переведено на deleteRecursivelyWithRetries с повторами. Тест на регистр путей: проба теперь отличает «регистрочувствительная ФС» от «файла-пробы нет» — иначе перенос фикстуры молча отключал бы регрессионную проверку на Windows, объявляя это свойством файловой системы. Проверено: сборка 5345, e2e code_review 11/11, golden сходится, фикстура чистая. --- .../mcp/server/tools/impl/CodeReviewTool.java | 9 +++ .../edt/mcp/server/utils/BslLsRunner.java | 78 +++++++++++++++++-- .../edt/mcp/server/utils/BslLsRunnerTest.java | 62 +++++++++++++-- tests/e2e/tools/test_code_review.py | 27 ++++++- 4 files changed, 162 insertions(+), 14 deletions(-) diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java index 58c6cc218..dcfdc2463 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/tools/impl/CodeReviewTool.java @@ -410,6 +410,15 @@ private static int countBySeverity(List findings, Severity severity) * requested project's own {@code src/} (a sibling project, or any workspace-visible * location) and have it silently analyzed instead of rejected as out of scope. * + * Lexical on purpose. This guard exists for a CALLER-supplied escape (an absolute path, or + * {@code ..} segments) - CLAUDE.md pre-push #4 - and normalize + startsWith answers exactly + * that. Following symlinks here would NOT close the symlink concern (a whole-project review + * walks {@code src/} and reads the same linked file with no modulePath involved) while it WOULD + * break the legitimate layout where shared BSL is linked into {@code src/}: the module stays + * reviewable project-wide but becomes unaddressable per-module. Deciding whether the engine may + * follow links out of a project is a srcDir-level policy question, not something to bolt onto + * this one check. + * * @param srcRoot the requested project's own {@code src} directory * @param candidate the resolved module file's on-disk location * @return {@code true} when {@code candidate} is {@code srcRoot} itself or a descendant of it diff --git a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java index 51c6bb319..849a78ff3 100644 --- a/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java +++ b/mcp/bundles/com.ditrix.edt.mcp.server/src/com/ditrix/edt/mcp/server/utils/BslLsRunner.java @@ -90,6 +90,7 @@ public final class BslLsRunner */ private static final int DRAIN_CHUNK_CHARS = 8_192; + /** * Bound on the engine's JSON report file size, checked BEFORE it is read into memory. A report * this large indicates a pathological/misconfigured run (or a corrupt engine process) — reading @@ -99,6 +100,17 @@ public final class BslLsRunner */ static final long MAX_REPORT_BYTES = 50_000_000L; + /** + * Bound on the engine CONFIG we read, checked BEFORE it is materialized — the same rule as + * {@link #MAX_REPORT_BYTES}, applied to the other file this class now opens. + *

+ * {@link #withoutFileWritingKeys} reads the project's {@code .bsl-language-server.json} into a + * String and then has Gson build a tree over it. A hand-written settings file is a few KB; a + * pathological or generated one is not, and both copies would land on the long-lived EDT heap + * before anything noticed. 2 MB is far above any real configuration and far below trouble. + */ + static final long MAX_CONFIG_BYTES = 2_000_000L; + private BslLsRunner() { } @@ -297,7 +309,19 @@ public static Result run(Request request) try { - return execute(java, jar, withoutFileWritingKeys(config, outputDir), request, outputDir); + File safeConfig; + try + { + safeConfig = withoutFileWritingKeys(config, outputDir); + } + catch (RuntimeException e) + { + // Sanitizing is the read-only guarantee; when it cannot be made, refusing is the + // answer. Converted here rather than thrown on, because run() is documented never + // to throw for an operational problem (CLAUDE.md don't #8). + return Result.error(e.getMessage()); + } + return execute(java, jar, safeConfig, request, outputDir); } finally { @@ -332,6 +356,32 @@ private static File discoverableConfig(File workspaceDir) return inHome.isFile() ? inHome : null; } + /** + * Reads at most {@code cap} bytes of {@code file}, so a file that grows during the read cannot + * defeat the bound the caller means to enforce. + * + * @param file the file to read + * @param cap the hard ceiling on retained bytes + * @return the bytes read (at most {@code cap}) + * @throws IOException when the file cannot be read + */ + private static byte[] readAtMost(Path file, long cap) throws IOException + { + try (java.io.InputStream in = Files.newInputStream(file); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) + { + byte[] chunk = new byte[8192]; + long total = 0; + int read; + while (total < cap && (read = in.read(chunk, 0, (int)Math.min(chunk.length, cap - total))) != -1) + { + out.write(chunk, 0, read); + total += read; + } + return out.toByteArray(); + } + } + /** * Keys in the engine's own configuration that make it WRITE a file. Verified against engine * 1.0.3: with {@code "traceLog": "bsl-trace.log"} in the project's @@ -365,7 +415,19 @@ static File withoutFileWritingKeys(File config, Path outputDir) } try { - String text = new String(Files.readAllBytes(config.toPath()), StandardCharsets.UTF_8); + // Bounded READ, not a size check followed by an unbounded one: the file can grow + // between the two (a generator still writing it), and then the "limit" would bound + // nothing. Read at most one byte past the cap and judge by what actually arrived. + byte[] raw = readAtMost(config.toPath(), MAX_CONFIG_BYTES + 1); + if (raw.length > MAX_CONFIG_BYTES) + { + // Deliberately NOT "pass it through unparsed": unparsed means traceLog survives, + // and the engine would then write that log into the project - from a tool that + // declares itself read-only. Refusing is the only answer that keeps the promise. + throw new IllegalStateException("the engine configuration " + config //$NON-NLS-1$ + + " is larger than " + MAX_CONFIG_BYTES + " bytes"); //$NON-NLS-1$ //$NON-NLS-2$ + } + String text = new String(raw, StandardCharsets.UTF_8); JsonElement rootEl = JsonParser.parseString(text); if (!rootEl.isJsonObject()) { @@ -387,9 +449,15 @@ static File withoutFileWritingKeys(File config, Path outputDir) } catch (IOException | RuntimeException e) { - Activator.logWarning("Could not strip file-writing keys from " + config //$NON-NLS-1$ - + "; passing it through unchanged: " + e.getMessage()); //$NON-NLS-1$ - return config; + // Cannot guarantee the engine will not write into the project, so do not let it try. + // The alternative - hand over the original - is exactly the read-only violation this + // method exists to prevent (verified against engine 1.0.3: traceLog lands in the + // project root), and a silent violation is worse than a refused review. + throw new IllegalStateException("Refusing to run: " + e.getMessage() //$NON-NLS-1$ + + ". code_review must strip file-writing settings (such as traceLog) from " //$NON-NLS-1$ + + config + " before handing it to the engine, or the engine would write into " //$NON-NLS-1$ + + "the project - and this tool is declared read-only. Fix or shrink that file, " //$NON-NLS-1$ + + "or remove it to fall back to the engine defaults.", e); //$NON-NLS-1$ } } diff --git a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java index b5ff28558..7abf6fcdc 100644 --- a/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java +++ b/mcp/tests/com.ditrix.edt.mcp.server.tests/src/com/ditrix/edt/mcp/server/utils/BslLsRunnerTest.java @@ -12,6 +12,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import java.io.File; import java.io.FileOutputStream; @@ -744,15 +745,62 @@ public void testConfigWithoutFileWritingKeysIsPassedThroughUnchanged() throws Ex } @Test - public void testMalformedConfigIsPassedThroughSoTheEngineReportsIt() throws Exception + public void testOversizedConfigIsRefusedRatherThanPassedThroughUnstripped() { - File dir = newFolder("cfg-broken"); //$NON-NLS-1$ + // The failure mode that matters is NOT the heap: it is what "give up safely" means. Passing + // an unparsed config to the engine leaves traceLog in it, and the engine then writes that + // log INTO the project - from a tool that declares readOnlyHint. So the oversize case must + // refuse, not degrade. + File dir = newFolder("cfg-huge"); //$NON-NLS-1$ File config = new File(dir, ".bsl-language-server.json"); //$NON-NLS-1$ - Files.write(config.toPath(), "{ this is not json".getBytes(StandardCharsets.UTF_8)); //$NON-NLS-1$ - File outputDir = newFolder("cfg-broken-out"); //$NON-NLS-1$ + StringBuilder padding = new StringBuilder(); + while (padding.length() < BslLsRunner.MAX_CONFIG_BYTES + 1024) + { + padding.append('x'); + } + try + { + Files.write(config.toPath(), + ("{\"traceLog\":\"bsl-trace.log\",\"pad\":\"" + padding + "\"}") //$NON-NLS-1$ + .getBytes(StandardCharsets.UTF_8)); + File outputDir = newFolder("cfg-huge-out"); //$NON-NLS-1$ + BslLsRunner.withoutFileWritingKeys(config, outputDir.toPath()); + fail("an oversized config must be refused, not handed to the engine unstripped"); + } + catch (IOException e) + { + fail("unexpected IO failure: " + e.getMessage()); //$NON-NLS-1$ + } + catch (IllegalStateException expected) + { + assertTrue("the refusal must explain itself: " + expected.getMessage(), //$NON-NLS-1$ + expected.getMessage().contains("read-only") //$NON-NLS-1$ + || expected.getMessage().contains("larger than")); //$NON-NLS-1$ + } + } - assertEquals("a malformed config must reach the engine, which diagnoses it better than we can", //$NON-NLS-1$ - config.getAbsolutePath(), - BslLsRunner.withoutFileWritingKeys(config, outputDir.toPath()).getAbsolutePath()); + @Test + public void testMalformedConfigIsRefusedForTheSameReason() + { + // Previously a broken config was passed through "so the engine can diagnose it" - but the + // engine reading it directly is precisely what leaves traceLog in play. Same verdict. + File dir = newFolder("cfg-bad"); //$NON-NLS-1$ + File config = new File(dir, ".bsl-language-server.json"); //$NON-NLS-1$ + try + { + Files.write(config.toPath(), "{ this is not json".getBytes(StandardCharsets.UTF_8)); //$NON-NLS-1$ + File outputDir = newFolder("cfg-bad-out"); //$NON-NLS-1$ + BslLsRunner.withoutFileWritingKeys(config, outputDir.toPath()); + fail("a config that cannot be parsed cannot be stripped, so it must be refused"); + } + catch (IOException e) + { + fail("unexpected IO failure: " + e.getMessage()); //$NON-NLS-1$ + } + catch (IllegalStateException expected) + { + assertTrue("the refusal must name the file: " + expected.getMessage(), //$NON-NLS-1$ + expected.getMessage().contains(".bsl-language-server.json")); //$NON-NLS-1$ + } } } diff --git a/tests/e2e/tools/test_code_review.py b/tests/e2e/tools/test_code_review.py index 4a59d79c8..a2a017f03 100644 --- a/tests/e2e/tools/test_code_review.py +++ b/tests/e2e/tools/test_code_review.py @@ -25,7 +25,7 @@ from harness import ( call, assert_ok, assert_contains, assert_not_contains, assert_error, assert_error_quality, assert_no_diff, e2e_test, PROJECT, E2ESkip, _fail, - split_markdown_row, + split_markdown_row, PROJECT_DIR, ) # Substrings that identify the actionable "engine not installed" error @@ -209,6 +209,22 @@ def test_exclude_rule_drops_only_the_named_rule(): assert_no_diff("a read-only review must not touch the project on disk") +def _filesystem_is_case_insensitive(): + """Does THIS filesystem resolve a differently-cased name to the same file? + + Asked of the fixture itself rather than guessed from the OS name: a case-sensitive volume can + be mounted on Windows and macOS is configurable either way. + """ + import os + probe = os.path.join(PROJECT_DIR, "src", "Configuration", "Configuration.mdo") + if not os.path.isfile(probe): + # NOT the same as "case-sensitive": the probe is simply gone (fixture moved, or + # MCP_PROJECT_REL points elsewhere). Returning False here would silently disable the + # regression this test guards on Windows while claiming the filesystem is case-sensitive. + _fail("case-sensitivity probe %s is missing - cannot tell what this filesystem does" % probe) + return os.path.isfile(probe.lower()) and os.path.isfile(probe.upper()) + + @e2e_test(tool="code_review", kind="read") def test_module_path_matching_is_case_insensitive_on_the_filesystem(): """A modulePath whose casing differs from the on-disk name must still scope correctly. @@ -219,7 +235,14 @@ def test_module_path_matching_is_case_insensitive_on_the_filesystem(): clean". A false clean is the worst answer this tool can give, which is why identity is decided by the filesystem (Files.isSameFile) rather than by string equality. - Needs a real filesystem, so it lives here rather than in a unit test.""" + Needs a real filesystem, so it lives here rather than in a unit test - and it only MEANS + anything where the filesystem is case-insensitive. On a case-sensitive volume (Linux) the + lowercased path names nothing, the tool correctly answers "Module not found", and asserting + findings there would be asserting the opposite of correct behaviour. So probe the filesystem + first and skip when case-only lookup genuinely cannot work.""" + if not _filesystem_is_case_insensitive(): + raise E2ESkip("case-sensitive filesystem: a case-only path variant names no file here, " + "so there is nothing for this test to assert") exact = _run_or_skip({"projectName": PROJECT, "modulePath": CALC_MODULE}, "module review, exact casing") assert_ok(exact, "module review with the canonical casing") assert_contains(exact.text, "MagicNumber", "the canonical casing must report the module's finding")