Skip to content

feat: Add F4 Diagnostics Linter Panel#117

Open
SSobol77 wants to merge 1 commit into
mainfrom
issue/f4-diagnostics-linter-panel
Open

feat: Add F4 Diagnostics Linter Panel#117
SSobol77 wants to merge 1 commit into
mainfrom
issue/f4-diagnostics-linter-panel

Conversation

@SSobol77

@SSobol77 SSobol77 commented Jun 26, 2026

Copy link
Copy Markdown
Owner

This PR implements the F4 Diagnostics / Linter Panel milestone.

Changes:

  • adds diagnostics data model and service layer
  • adds Ruff diagnostics provider
  • adds deterministic diagnostics sorting and provider state handling
  • adds async/non-blocking diagnostics refresh
  • adds F4 Diagnostics Panel UI
  • supports F4 open/close, Esc close, Ctrl+Q global exit
  • supports manual diagnostics runs with r/R
  • supports Enter navigation to diagnostic location
  • supports d/Space selected diagnostic details popup
  • keeps the right-side panel as the only authoritative diagnostics list
  • adds selected diagnostic source-line/gutter highlight
  • adds PASS state with green success styling
  • adds diagnostics architecture documentation
  • adds tests for diagnostics service, F4 panel behavior, keybindings, Ruff parity, selected diagnostic details, PASS state, and line-highlight behavior

Runtime behavior:

  • F4 does not automatically run Ruff
  • no synchronous Ruff execution on panel open
  • refresh is explicit and bounded
  • repeated refreshes are coalesced
  • stale generations are discarded
  • missing Ruff is handled as controlled diagnostic/status, not a crash
  • centered popup is details-only and never a second diagnostics list
  • selected diagnostic highlight does not move the editor cursor; Enter remains the jump action

Evidence:

  • Ruff CLI JSON count: 11
  • provider parsed count: 11
  • provider run count: 11
  • service snapshot count: 11
  • panel visible count: 11/11

Manual smoke confirmed:

  • F4 opens Diagnostics without auto-running Ruff
  • r runs current-file diagnostics
  • right-side panel is the only authoritative diagnostics list
  • Enter jumps to the selected diagnostic
  • d/Space opens selected Diagnostic details only
  • selected diagnostic highlights the source line gutter
  • clean run shows Diagnostics: PASS / No issues found.
  • PASS is rendered with green success styling
  • Ctrl+Q exits cleanly
  • fresh logs contain no traceback/logging/panel crash markers

Validation:

  • make clean-logs
  • uv run ruff check src tests
  • uv run ruff format --check src tests
  • uv run python scripts/check_runtime_imports.py
  • uv run pytest -q tests/core/test_file_roundtrip_stability.py
  • uv run pytest -q tests/core/test_open_preserves_indentation.py
  • uv run pytest -q tests/core/test_diagnostics_service.py
  • uv run pytest -q tests/ui/test_diagnostics_panel.py
  • uv run pytest -q tests/characterization/test_existing_keybindings.py
  • uv run pytest -q tests/ui
  • uv run pytest -q tests/utils tests/core tests/extensions tests/packaging tests/docs

Final broad validation:

  • 850 passed, 4 skipped

Non-goals:

  • no F7 AI work
  • no F11 PySH / terminal-console work
  • no F10 File Browser changes
  • no TextMate tokenizer changes
  • no theme numbering changes
  • no plugin layer
  • no VMLab/QEMU/QMP work

Summary by CodeRabbit

  • New Features

    • Added a new Diagnostics panel with keyboard navigation, refresh controls, and a details popup.
    • Introduced unified diagnostics handling with consistent sorting, display, and status reporting.
    • Added support for highlighting diagnostic locations directly in the editor gutter.
  • Bug Fixes

    • Improved handling for missing or malformed diagnostic data.
    • Kept diagnostics visible with relative paths and readable truncation in narrow layouts.
    • Added safer navigation and clearer messages when jumping to diagnostic locations.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a unified diagnostics schema, Ruff-backed diagnostics service, editor integration, and a new F4 diagnostics panel. It also updates diagnostics documentation, navigation, highlight handling, and tests for parsing, refresh scheduling, rendering, details popups, and jump behavior.

Changes

Diagnostics subsystem

Layer / File(s) Summary
Schema and display contracts
docs/extensions/diagnostics-model.md, src/ecli/diagnostics/__init__.py, src/ecli/diagnostics/display.py, src/ecli/diagnostics/models.py, tests/core/test_diagnostics_service.py
Defines the normalized diagnostics contract, path formatting helpers, and deterministic sorting behavior.
Diagnostics execution backend
src/ecli/diagnostics/service.py, src/ecli/diagnostics/ruff_provider.py, tests/core/test_diagnostics_service.py
Adds the diagnostics worker, Ruff provider, and backend tests for parsing and refresh scheduling.
Editor wiring
src/ecli/core/Ecli.py, src/ecli/integrations/LinterBridge.py, src/ecli/ui/DrawScreen.py, src/ecli/ui/KeyBinder.py, src/ecli/ui/PanelManager.py, tests/characterization/test_existing_keybindings.py, tests/ui/test_diagnostics_panel.py
Connects diagnostics refresh, panel toggling, highlight state, and shared UI handling in the editor and bridge.
Panel shell
src/ecli/ui/panels.py, tests/ui/test_diagnostics_panel.py
Implements the diagnostics side panel’s open/close, refresh, selection, and status rendering.
Details and navigation
src/ecli/core/Ecli.py, src/ecli/ui/panels.py, tests/ui/test_diagnostics_panel.py
Formats diagnostic rows and details popups, and adds diagnostic jump navigation.

Sequence Diagram(s)

sequenceDiagram
  participant DiagnosticsPanel
  participant LinterBridge
  participant DiagnosticsService
  participant RuffDiagnosticProvider

  DiagnosticsPanel->>LinterBridge: request_diagnostics_refresh(scope)
  LinterBridge->>DiagnosticsService: request_refresh(...)
  DiagnosticsService->>RuffDiagnosticProvider: run(request)
  RuffDiagnosticProvider-->>DiagnosticsService: DiagnosticResult
  LinterBridge->>DiagnosticsService: drain_results()
  DiagnosticsService-->>LinterBridge: completed results
  LinterBridge-->>DiagnosticsPanel: snapshot and status update
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding the F4 diagnostics/linter panel.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue/f4-diagnostics-linter-panel

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
D Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/ecli/core/Ecli.py`:
- Around line 5442-5516: The goto_diagnostic method in Ecli.py is slightly over
the cognitive complexity limit because it repeats the same
validate/status/log/return flow in multiple branches. Extract the repeated
failure handling into a small helper such as _report_nav_failure (or equivalent)
and use it for the missing-file, open-file, line-out-of-range, and
column-out-of-range paths while keeping goto_diagnostic focused on navigation.
Keep the existing redraw contract and behavior intact, and use the helper from
goto_diagnostic to reduce branching without changing the success path.

In `@src/ecli/diagnostics/display.py`:
- Around line 37-48: The truncate_middle helper can exceed the requested width
by one character when max_width is 4, which can break callers like _fit_location
that rely on exact column budgeting. Update truncate_middle in display.py to add
a <= 5 early-return guard before the split-and-ellipsis logic, so very small
widths never produce output longer than max_width. Keep the fix localized to
truncate_middle and preserve the existing behavior for wider strings.

In `@src/ecli/diagnostics/models.py`:
- Around line 21-45: Remove order=True from the Diagnostic dataclass in
Diagnostic, since sort_diagnostics() already uses Diagnostic.sort_key() for
canonical ordering. Keep frozen=True, and ensure any comparisons rely on the
explicit sort key instead of the generated dataclass ordering so code fields
with None do not trigger TypeError.

In `@src/ecli/diagnostics/ruff_provider.py`:
- Around line 266-269: The severity mapping in _severity_for_code currently
treats Ruff diagnostics with a missing code as warnings, which causes syntax
parse failures to be underreported. Update the Ruff diagnostics handling in
ruff_provider.py so syntax diagnostics are identified explicitly, rather than
depending only on rule codes in _severity_for_code, and map those parse/syntax
failures to error while keeping normal rule-based severity behavior intact.

In `@src/ecli/diagnostics/service.py`:
- Around line 169-210: The `_run_providers` method in `DiagnosticsService` mixes
result accumulation with final status/message resolution, pushing its cognitive
complexity over the limit. Extract the branching logic that determines the final
status and message into a small module-level helper such as
`_resolve_status_message(...)`, and keep `_run_providers` focused on iterating
providers, collecting diagnostics/messages, and calling `sort_diagnostics(...)`
before building the final `DiagnosticResult`.

In `@src/ecli/ui/panels.py`:
- Around line 2386-2449: The diagnostic row fitting in _format_diagnostic,
_fit_diagnostic_row, and _fit_location is using len() for terminal geometry
instead of display width, which can mis-size Unicode text in curses panels.
Update all width comparisons and truncation decisions in these helpers to use
wcwidth/wcswidth-aware measurements and matching truncate helpers so rendered
rows stay within the panel width. Make the same adjustment in the related
row/detail fit logic referenced by the comment so all diagnostic display paths
use consistent display-width calculations.
- Around line 2462-2470: The _diagnostics_project_root helper currently prefers
editor.filename and returns the active file’s directory before considering
project_service.root, which causes workspace diagnostics to anchor to the wrong
location. Update _diagnostics_project_root in the panels.py logic to read
service_registry.project_service.root first, and only fall back to deriving a
path from editor.filename when the project root is unavailable.
- Around line 2608-2622: Keep the selected diagnostic highlight tied to the
snapshot passed into _sync_selected_diagnostic_highlight() instead of re-reading
state via _selected_diagnostic(). Use the supplied diagnostics tuple to resolve
the selected Diagnostic, then pass that same object to
set_diagnostic_line_highlight with generation=snapshot.generation so the
highlight cannot drift across async refreshes.
- Around line 2263-2273: The refresh handler in _request_refresh currently
returns the bridge’s raw result, which can let handled keys fall through when
request_diagnostics_refresh returns None or False. Update _request_refresh so it
always reports the refresh key as consumed once the panel has processed it: keep
the existing cleanup and bridge call, but return a handled/true result
regardless of the requester’s return value. Use _request_refresh and
request_diagnostics_refresh as the main touchpoints.
- Around line 2191-2245: The draw() method in panels.py is doing too much work
each frame and formats every diagnostic before slicing, which causes unnecessary
UI-thread overhead for large result sets. Refactor draw() by splitting the
status/scroll/list rendering logic into smaller helpers, and change the
diagnostics rendering path so only the visible diagnostics in the current
viewport are passed through _format_diagnostic() before drawing. Keep the
behavior of draw(), _status_lines_with_viewport(), and _draw_details_popup()
unchanged while reducing the cognitive complexity of draw().

In `@tests/ui/test_diagnostics_panel.py`:
- Around line 410-413: The tests in this block are still using literal temp
filenames like /tmp/f4_bad.py and /tmp/example.py instead of the existing
tmp_path fixture. Update the affected test setup in
test_pass_status_message_uses_success_style_hook and the other matching tests in
this file to derive filenames from tmp_path, keeping the same fake-editor
behavior while removing hardcoded publicly-writable paths. Use the same pattern
already used elsewhere in tests/ui/test_diagnostics_panel.py so the suite stays
consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cb277273-cbcb-4bec-b2ba-a67cfe7609f6

📥 Commits

Reviewing files that changed from the base of the PR and between d4f1a18 and 93fc009.

📒 Files selected for processing (15)
  • docs/extensions/diagnostics-model.md
  • src/ecli/core/Ecli.py
  • src/ecli/diagnostics/__init__.py
  • src/ecli/diagnostics/display.py
  • src/ecli/diagnostics/models.py
  • src/ecli/diagnostics/ruff_provider.py
  • src/ecli/diagnostics/service.py
  • src/ecli/integrations/LinterBridge.py
  • src/ecli/ui/DrawScreen.py
  • src/ecli/ui/KeyBinder.py
  • src/ecli/ui/PanelManager.py
  • src/ecli/ui/panels.py
  • tests/characterization/test_existing_keybindings.py
  • tests/core/test_diagnostics_service.py
  • tests/ui/test_diagnostics_panel.py

Comment thread src/ecli/core/Ecli.py
Comment on lines +5442 to +5516
def goto_diagnostic(self, diagnostic: Any) -> bool:
"""Navigate to a normalized diagnostic location."""
file_path = str(getattr(diagnostic, "file_path", "") or "")
try:
target_line = max(1, int(getattr(diagnostic, "line", 1)))
target_column = max(1, int(getattr(diagnostic, "column", 1)))
except (TypeError, ValueError):
self._set_status_message("Diagnostics: invalid diagnostic location.")
return True

if not file_path:
self._set_status_message("Diagnostics: selected item has no file path.")
return True

label = self._diagnostic_display_label(file_path)
current_path = os.path.abspath(self.filename) if self.filename else None
target_path = os.path.abspath(file_path)
if current_path != target_path:
if not os.path.exists(target_path):
self._set_status_message(
f"Diagnostics: file not available: {label}"
)
logging.warning(
"Diagnostic navigation failed: file not available: %s",
target_path,
)
return True
self.open_file(target_path)
current_path = os.path.abspath(self.filename) if self.filename else None
if current_path != target_path:
self._set_status_message(f"Diagnostics: could not open {label}")
logging.warning(
"Diagnostic navigation failed: open_file did not load target: %s",
target_path,
)
return True

line_count = len(self.text)
if target_line > line_count:
self._set_status_message(
f"Diagnostics: line out of range for {label}:{target_line}:{target_column}"
)
logging.warning(
"Diagnostic navigation failed: line out of range: file=%s line=%s line_count=%s",
target_path,
target_line,
line_count,
)
return True

line_text = self.text[target_line - 1] if self.text else ""
max_column = len(line_text) + 1
if target_column > max_column:
self._set_status_message(
f"Diagnostics: column out of range for {label}:{target_line}:{target_column}"
)
logging.warning(
"Diagnostic navigation failed: column out of range: file=%s line=%s column=%s max_column=%s",
target_path,
target_line,
target_column,
max_column,
)
return True

self.cursor_y = target_line - 1
self.cursor_x = target_column - 1
self._ensure_cursor_in_bounds()
self._clamp_scroll()
panel_manager = getattr(self, "panel_manager", None)
if not (panel_manager and panel_manager.is_panel_active()):
self.focus = "editor"
self._force_full_redraw = True
self._set_status_message(f"Jumped to {label}:{target_line}:{target_column}")
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

goto_diagnostic cognitive complexity slightly exceeds the project threshold.

SonarCloud flags this method (complexity 16 vs 15 allowed). The repeated "validate → set status → log → return True" blocks for missing-file, line-out-of-range and column-out-of-range are good candidates for a small extraction (e.g. a _report_nav_failure(label, detail) helper) to bring it back under the limit. The "always returns the same value" finding is expected here, since the boolean is the editor's redraw contract shared with the other action handlers.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[failure] 5442-5442: Refactor this method to not always return the same value.

See more on https://sonarcloud.io/project/issues?id=SSobol77_ecli&issues=AZ8ByvaLVhkxC5OQdjO_&open=AZ8ByvaLVhkxC5OQdjO_&pullRequest=117


[failure] 5442-5442: Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=SSobol77_ecli&issues=AZ8ByvaLVhkxC5OQdjPA&open=AZ8ByvaLVhkxC5OQdjPA&pullRequest=117

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/core/Ecli.py` around lines 5442 - 5516, The goto_diagnostic method
in Ecli.py is slightly over the cognitive complexity limit because it repeats
the same validate/status/log/return flow in multiple branches. Extract the
repeated failure handling into a small helper such as _report_nav_failure (or
equivalent) and use it for the missing-file, open-file, line-out-of-range, and
column-out-of-range paths while keeping goto_diagnostic focused on navigation.
Keep the existing redraw contract and behavior intact, and use the helper from
goto_diagnostic to reduce branching without changing the success path.

Source: Linters/SAST tools

Comment on lines +37 to +48
def truncate_middle(text: str, max_width: int) -> str:
"""Truncate text in the middle using ASCII dots."""
if max_width <= 0:
return ""
if len(text) <= max_width:
return text
if max_width <= 3:
return text[:max_width]
keep = max_width - 3
left = max(1, keep // 2)
right = max(1, keep - left)
return f"{text[:left]}...{text[-right:]}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP '\btruncate_middle\s*\(' --type=py -C2

Repository: SSobol77/ecli

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files of interest:\n'
git ls-files 'src/ecli/diagnostics/*' 'src/ecli/*' | sed -n '1,200p'

printf '\nDefinitions/usages of truncate_middle:\n'
rg -n "truncate_middle" src -S

printf '\nSearch for width calculations in diagnostics UI code:\n'
rg -n "max_width|width" src/ecli/diagnostics src/ecli -S | sed -n '1,240p'

Repository: SSobol77/ecli

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'display.py excerpt:\n'
sed -n '1,120p' src/ecli/diagnostics/display.py | cat -n

printf '\npanels.py excerpt around _fit_location:\n'
sed -n '2400,2460p' src/ecli/ui/panels.py | cat -n

printf '\npanels.py excerpt around caller:\n'
sed -n '2380,2445p' src/ecli/ui/panels.py | cat -n

Repository: SSobol77/ecli

Length of output: 9589


truncate_middle can overflow by one column at max_width == 4. src/ecli/ui/panels.py::_fit_location() can pass that width when a long path is being squeezed into the location column. Add a <= 5 guard before the split logic so the output always stays within budget.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/diagnostics/display.py` around lines 37 - 48, The truncate_middle
helper can exceed the requested width by one character when max_width is 4,
which can break callers like _fit_location that rely on exact column budgeting.
Update truncate_middle in display.py to add a <= 5 early-return guard before the
split-and-ellipsis logic, so very small widths never produce output longer than
max_width. Keep the fix localized to truncate_middle and preserve the existing
behavior for wider strings.

Comment on lines +21 to +45
@dataclass(frozen=True, order=True)
class Diagnostic:
"""One normalized diagnostic item shown by ECLI."""

file_path: str
line: int
column: int
severity: DiagnosticSeverity
code: str | None
message: str
source: str
fix_hint: str | None = None
suggested_code: str | None = None

def sort_key(self) -> tuple[int, str, int, int, str, str, str]:
"""Return the deterministic ordering key for diagnostics."""
return (
_SEVERITY_ORDER[self.severity],
self.file_path,
self.line,
self.column,
self.source,
self.code or "",
self.message,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP '\bsorted\s*\([^)]*\)' --type=py -g '!**/test*' | rg -i diagnostic
rg -nP '(<|>|<=|>=)\s*(self\.)?diagnostic' --type=py -C1

Repository: SSobol77/ecli

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== files ==\n'
git ls-files | rg '^src/ecli/diagnostics/|^src/.*\.py$' | sed -n '1,200p'

printf '\n== Diagnostic definition ==\n'
nl -ba src/ecli/diagnostics/models.py | sed -n '1,120p'

printf '\n== Diagnostic usages ==\n'
rg -n '\bDiagnostic\b' src -g '*.py'

printf '\n== sort/sorted usages mentioning diagnostics ==\n'
rg -n '\bsorted\s*\(|\.sort\(' src -g '*.py' | rg -i 'diagnostic|sort_key|severity|file_path'

printf '\n== comparisons involving diagnostics ==\n'
rg -n '(<|>|<=|>=|==|!=)\s*(self\.)?(diagnostic|Diagnostic)\b' src -g '*.py' -C 1

Repository: SSobol77/ecli

Length of output: 2679


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Diagnostic model ==\n'
sed -n '1,140p' src/ecli/diagnostics/models.py | cat -n

printf '\n== Diagnostic references ==\n'
rg -n '\bDiagnostic\b' src -g '*.py'

printf '\n== sort/sorted uses in diagnostics ==\n'
rg -n '\bsorted\s*\(|\.sort\(' src/ecli -g '*.py'

printf '\n== comparisons involving Diagnostic ==\n'
rg -n '(<|>|<=|>=|==|!=)\s*(self\.)?(diagnostic|Diagnostic)\b' src -g '*.py' -C 2

printf '\n== sort_key / sort_diagnostics ==\n'
rg -n '\bsort_key\b|\bsort_diagnostics\b' src/ecli -g '*.py' -C 2

Repository: SSobol77/ecli

Length of output: 14116


🏁 Script executed:

python3 - <<'PY'
from dataclasses import dataclass

`@dataclass`(order=True)
class Diagnostic:
    file_path: str
    line: int
    column: int
    severity: str
    code: str | None
    message: str
    source: str

a = Diagnostic("a", 1, 1, "error", None, "x", "ruff")
b = Diagnostic("a", 1, 1, "error", "E001", "x", "ruff")

try:
    print(a < b)
except Exception as exc:
    print(type(exc).__name__, exc)
PY

Repository: SSobol77/ecli

Length of output: 221


Drop order=True from Diagnostic. sort_diagnostics() already provides the canonical ordering, and the generated comparison methods can raise TypeError when code is None vs str. No code in the repo relies on Diagnostic’s natural ordering, so frozen=True is enough.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/diagnostics/models.py` around lines 21 - 45, Remove order=True from
the Diagnostic dataclass in Diagnostic, since sort_diagnostics() already uses
Diagnostic.sort_key() for canonical ordering. Keep frozen=True, and ensure any
comparisons rely on the explicit sort key instead of the generated dataclass
ordering so code fields with None do not trigger TypeError.

Comment on lines +266 to +269
def _severity_for_code(code: str | None) -> DiagnosticSeverity:
if code and (code in _ERROR_CODES or code.startswith("E9")):
return "error"
return "warning"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Ruff check JSON output syntax error code "invalid-syntax" vs "E999"

💡 Result:

In Ruff, the error code "E999" was the historical identifier for syntax errors, while "invalid-syntax" is the current terminology used following the removal of E999 as a configurable rule [1][2]. Key distinctions and context include: 1. Deprecation and Removal: E999 was originally used to report syntax errors [3][2]. To ensure that syntax errors are always reported and cannot be silenced, Ruff deprecated and subsequently removed E999 as a formal rule [3][4][2]. 2. JSON Output Changes: With the removal of E999, the JSON output format for syntax errors was updated [5][4]. Fields such as "code", "noqa_row", and "url" are now set to null for syntax errors, reflecting that they are no longer treated as standard configurable lint rules [5][4]. 3. Impact on Integrations: Because E999 has been removed, tools or integrations (such as language servers) that previously relied on checking for the "E999" error code must be updated to handle the new state or the identifier "invalid-syntax" [1][6]. If an integration expects "E999" to appear in the JSON output, it will no longer find it; instead, it must handle the diagnostic appropriately based on the updated structure where the code field is null [5][4]. Essentially, "E999" no longer exists as an active rule in Ruff, and syntax errors are now mandatory, non-suppressible diagnostics [3][4][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the provider and relevant tests, then inspect only the relevant slices.
git ls-files 'src/ecli/diagnostics/ruff_provider.py' 'tests/core/test_diagnostics_service.py'

printf '\n--- ruff_provider.py outline ---\n'
ast-grep outline src/ecli/diagnostics/ruff_provider.py --view expanded || true

printf '\n--- test_diagnostics_service.py outline ---\n'
ast-grep outline tests/core/test_diagnostics_service.py --view expanded || true

printf '\n--- ruff_provider.py relevant lines ---\n'
sed -n '240,290p' src/ecli/diagnostics/ruff_provider.py

printf '\n--- test_diagnostics_service.py relevant lines ---\n'
sed -n '70,100p' tests/core/test_diagnostics_service.py

Repository: SSobol77/ecli

Length of output: 5163


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Use read-only probes to determine actual Ruff JSON syntax-error code(s)
# from docs and package behavior context, without executing repository code.
python3 - <<'PY'
import json
import textwrap

samples = [
    # historical style
    {"code": "E999"},
    # current style
    {"code": "invalid-syntax"},
    # null-style syntax diagnostics seen in some integrations
    {"code": None},
]

for s in samples:
    print(json.dumps(s))
PY

Repository: SSobol77/ecli

Length of output: 210


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the Ruff provider and test fixture around syntax-error handling.
sed -n '1,220p' src/ecli/diagnostics/ruff_provider.py
printf '\n---\n'
sed -n '1,130p' tests/core/test_diagnostics_service.py

Repository: SSobol77/ecli

Length of output: 11238


🌐 Web query:

Ruff syntax-error JSON diagnostics code invalid-syntax E999 code field null

💡 Result:

In recent versions of Ruff, the error code E999 has been removed and syntax errors are no longer treated as standard linter rules [1][2]. This change, which culminated in its formal removal in version 0.8.0, means that syntax errors are always reported and cannot be suppressed, ignored, or disabled via configuration [3][1]. As a consequence of this change, the JSON diagnostic output for syntax errors reflects the absence of a rule code: the code field in the JSON output is set to null [3][4][5]. This distinguishes syntax errors from standard linting violations, which typically have a string-based rule identifier (e.g., F401) in the code field [3][6]. While some older integrations or documentation may refer to the invalid-syntax identifier or E999, the current behavior is that syntax errors do not carry a rule code, resulting in a null value in the diagnostic JSON [3][5][7]. Integrations that previously relied on checking for code == "E999" must be updated to handle the null code value or to specifically look for syntax error diagnostics as a separate category [8][9][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show only the relevant regions around severity mapping and the test fixture.
sed -n '250,280p' src/ecli/diagnostics/ruff_provider.py
printf '\n---\n'
sed -n '80,95p' tests/core/test_diagnostics_service.py

Repository: SSobol77/ecli

Length of output: 1521


Treat Ruff syntax errors as error. Ruff JSON syntax diagnostics can come through with code: null, so _severity_for_code currently falls back to warning for blocking parse failures. Handle syntax diagnostics explicitly instead of relying only on rule codes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/diagnostics/ruff_provider.py` around lines 266 - 269, The severity
mapping in _severity_for_code currently treats Ruff diagnostics with a missing
code as warnings, which causes syntax parse failures to be underreported. Update
the Ruff diagnostics handling in ruff_provider.py so syntax diagnostics are
identified explicitly, rather than depending only on rule codes in
_severity_for_code, and map those parse/syntax failures to error while keeping
normal rule-based severity behavior intact.

Comment on lines +169 to +210
def _run_providers(self, request: DiagnosticRequest) -> DiagnosticResult:
enabled = self._enabled_providers()
if not enabled:
return DiagnosticResult(
generation=request.generation,
diagnostics=(),
status="skipped",
message="Diagnostics providers are disabled.",
provider_states=self.provider_states(),
)

diagnostics: list[Diagnostic] = []
messages: list[str] = []
status = "ready"
saw_skipped = False
for provider in enabled:
result = provider.run(request)
diagnostics.extend(result.diagnostics)
if result.status == "error":
status = "error"
elif result.status == "skipped" and status != "error":
saw_skipped = True
if result.message:
messages.append(result.message)

sorted_diagnostics = sort_diagnostics(diagnostics)
if status == "error":
message = messages[-1] if messages else "Diagnostics failed."
elif sorted_diagnostics:
message = f"Diagnostics: {len(sorted_diagnostics)} issue(s)."
elif saw_skipped:
status = "skipped"
message = messages[-1] if messages else "Diagnostics skipped."
else:
message = "Diagnostics: PASS — no issues found."
return DiagnosticResult(
generation=request.generation,
diagnostics=sorted_diagnostics,
status=status,
message=message,
provider_states=self.provider_states(),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reduce _run_providers cognitive complexity.

SonarCloud flags this at 16 (limit 15). The aggregation loop plus the multi-branch status/message resolution can be split into a small helper that computes the final (status, message) from collected results, keeping the loop focused on accumulation.

♻️ Suggested extraction
-        sorted_diagnostics = sort_diagnostics(diagnostics)
-        if status == "error":
-            message = messages[-1] if messages else "Diagnostics failed."
-        elif sorted_diagnostics:
-            message = f"Diagnostics: {len(sorted_diagnostics)} issue(s)."
-        elif saw_skipped:
-            status = "skipped"
-            message = messages[-1] if messages else "Diagnostics skipped."
-        else:
-            message = "Diagnostics: PASS — no issues found."
+        sorted_diagnostics = sort_diagnostics(diagnostics)
+        status, message = _resolve_status_message(
+            status, sorted_diagnostics, saw_skipped, messages
+        )
         return DiagnosticResult(

Add a module-level _resolve_status_message(...) helper containing the branch logic.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[failure] 169-169: Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=SSobol77_ecli&issues=AZ8ByveSVhkxC5OQdjPE&open=AZ8ByveSVhkxC5OQdjPE&pullRequest=117

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/diagnostics/service.py` around lines 169 - 210, The `_run_providers`
method in `DiagnosticsService` mixes result accumulation with final
status/message resolution, pushing its cognitive complexity over the limit.
Extract the branching logic that determines the final status and message into a
small module-level helper such as `_resolve_status_message(...)`, and keep
`_run_providers` focused on iterating providers, collecting
diagnostics/messages, and calling `sort_diagnostics(...)` before building the
final `DiagnosticResult`.

Source: Linters/SAST tools

Comment thread src/ecli/ui/panels.py
Comment on lines +2263 to +2273
def _request_refresh(self, scope: str) -> bool:
bridge = getattr(self.editor, "linter_bridge", None)
requester = getattr(bridge, "request_diagnostics_refresh", None)
if not callable(requester):
self.editor._set_status_message("Diagnostics service is not available.")
return True
self._clear_details_popup()
self._clear_diagnostic_line_highlight()
self._clear_legacy_lint_popup()
self._last_navigation_message = None
return bool(requester(scope=scope))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Always consume handled refresh keys.

Line 2273 returns the bridge result directly; if the requester returns None or False for a coalesced request, r/R is reported unhandled and can fall through to other input handling even though the panel already processed it.

Suggested fix
-        return bool(requester(scope=scope))
+        requester(scope=scope)
+        return True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _request_refresh(self, scope: str) -> bool:
bridge = getattr(self.editor, "linter_bridge", None)
requester = getattr(bridge, "request_diagnostics_refresh", None)
if not callable(requester):
self.editor._set_status_message("Diagnostics service is not available.")
return True
self._clear_details_popup()
self._clear_diagnostic_line_highlight()
self._clear_legacy_lint_popup()
self._last_navigation_message = None
return bool(requester(scope=scope))
def _request_refresh(self, scope: str) -> bool:
bridge = getattr(self.editor, "linter_bridge", None)
requester = getattr(bridge, "request_diagnostics_refresh", None)
if not callable(requester):
self.editor._set_status_message("Diagnostics service is not available.")
return True
self._clear_details_popup()
self._clear_diagnostic_line_highlight()
self._clear_legacy_lint_popup()
self._last_navigation_message = None
requester(scope=scope)
return True
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/ui/panels.py` around lines 2263 - 2273, The refresh handler in
_request_refresh currently returns the bridge’s raw result, which can let
handled keys fall through when request_diagnostics_refresh returns None or
False. Update _request_refresh so it always reports the refresh key as consumed
once the panel has processed it: keep the existing cleanup and bridge call, but
return a handled/true result regardless of the requester’s return value. Use
_request_refresh and request_diagnostics_refresh as the main touchpoints.

Comment thread src/ecli/ui/panels.py
Comment on lines +2386 to +2449
def _format_diagnostic(self, diagnostic: Diagnostic) -> str:
severity = self._SEVERITY_LABELS.get(diagnostic.severity, "?")
source = truncate_end(str(diagnostic.source or "?"), 12)
prefix = f"{severity} {source} "
path = self._display_path(diagnostic.file_path)
location_suffix = f":{diagnostic.line}:{diagnostic.column}"
message = self._row_message(diagnostic)
content_width = max(1, self.width - 2)

full_row = f"{prefix}{path}{location_suffix} {message}"
if len(full_row) <= content_width:
return full_row
return self._fit_diagnostic_row(
prefix=prefix,
path=path,
location_suffix=location_suffix,
message=message,
content_width=content_width,
)

def _fit_diagnostic_row(
self,
*,
prefix: str,
path: str,
location_suffix: str,
message: str,
content_width: int,
) -> str:
if content_width <= len(prefix):
return truncate_end(prefix, content_width)

usable = content_width - len(prefix)
full_location = f"{path}{location_suffix}"
if usable <= len(location_suffix) + 2:
return truncate_end(f"{prefix}{full_location}", content_width)

min_message_width = min(len(message), max(7, content_width // 4))
location_width = min(
len(full_location),
max(len(location_suffix) + 1, usable - min_message_width - 1),
)
message_width = max(1, usable - location_width - 1)
if message_width < min_message_width and location_width > len(location_suffix) + 1:
deficit = min_message_width - message_width
shrink_by = min(deficit, location_width - len(location_suffix) - 1)
location_width -= shrink_by
message_width += shrink_by

location = self._fit_location(path, location_suffix, location_width)
message_width = max(1, usable - len(location) - 1)
row = f"{prefix}{location} {truncate_end(message, message_width)}"
return truncate_end(row, content_width)

def _fit_location(self, path: str, suffix: str, max_width: int) -> str:
location = f"{path}{suffix}"
if len(location) <= max_width:
return location
basename = Path(path).name or path
basename_location = f"{basename}{suffix}"
if len(basename_location) <= max_width:
return basename_location
path_width = max(1, max_width - len(suffix))
return f"{truncate_middle(basename, path_width)}{suffix}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Use display width, not len(), for curses fitting.

These row/detail fit paths use character counts while rendering to fixed terminal cells. Unicode paths or diagnostic messages can exceed the panel width despite passing the len() checks, causing clipped rows, border overwrite, or caught curses.error drops. Use wcswidth/wcwidth-aware truncation for all width comparisons and truncation here. As per coding guidelines, “Rendering risk is likely concentrated around: (1) multiple code paths mutating curses or terminal state, (2) len()-based geometry where display width is required.”

Also applies to: 2572-2578

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/ui/panels.py` around lines 2386 - 2449, The diagnostic row fitting
in _format_diagnostic, _fit_diagnostic_row, and _fit_location is using len() for
terminal geometry instead of display width, which can mis-size Unicode text in
curses panels. Update all width comparisons and truncation decisions in these
helpers to use wcwidth/wcswidth-aware measurements and matching truncate helpers
so rendered rows stay within the panel width. Make the same adjustment in the
related row/detail fit logic referenced by the comment so all diagnostic display
paths use consistent display-width calculations.

Source: Coding guidelines

Comment thread src/ecli/ui/panels.py
Comment on lines +2462 to +2470
def _diagnostics_project_root(self) -> str | None:
filename = getattr(self.editor, "filename", None)
if filename:
path = Path(str(filename)).expanduser()
return str(path.parent if path.suffix else path)
registry = getattr(self.editor, "service_registry", None)
project_service = getattr(registry, "project_service", None)
root = getattr(project_service, "root", None)
return str(root) if root else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Prefer the project service root for workspace diagnostics.

When editor.filename is set, Line 2466 uses the active file’s parent and never reaches project_service.root; workspace diagnostics then display relative to the current file’s directory instead of the project root. Use the service root first, with the filename parent as fallback.

Suggested fix
     def _diagnostics_project_root(self) -> str | None:
-        filename = getattr(self.editor, "filename", None)
-        if filename:
-            path = Path(str(filename)).expanduser()
-            return str(path.parent if path.suffix else path)
         registry = getattr(self.editor, "service_registry", None)
         project_service = getattr(registry, "project_service", None)
         root = getattr(project_service, "root", None)
-        return str(root) if root else None
+        if root:
+            return str(root)
+        filename = getattr(self.editor, "filename", None)
+        if filename:
+            path = Path(str(filename)).expanduser()
+            return str(path.parent if path.suffix else path)
+        return None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _diagnostics_project_root(self) -> str | None:
filename = getattr(self.editor, "filename", None)
if filename:
path = Path(str(filename)).expanduser()
return str(path.parent if path.suffix else path)
registry = getattr(self.editor, "service_registry", None)
project_service = getattr(registry, "project_service", None)
root = getattr(project_service, "root", None)
return str(root) if root else None
def _diagnostics_project_root(self) -> str | None:
registry = getattr(self.editor, "service_registry", None)
project_service = getattr(registry, "project_service", None)
root = getattr(project_service, "root", None)
if root:
return str(root)
filename = getattr(self.editor, "filename", None)
if filename:
path = Path(str(filename)).expanduser()
return str(path.parent if path.suffix else path)
return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/ui/panels.py` around lines 2462 - 2470, The
_diagnostics_project_root helper currently prefers editor.filename and returns
the active file’s directory before considering project_service.root, which
causes workspace diagnostics to anchor to the wrong location. Update
_diagnostics_project_root in the panels.py logic to read
service_registry.project_service.root first, and only fall back to deriving a
path from editor.filename when the project root is unavailable.

Comment thread src/ecli/ui/panels.py
Comment on lines +2608 to +2622
def _sync_selected_diagnostic_highlight(
self,
snapshot: DiagnosticsSnapshot | None,
diagnostics: tuple[Diagnostic, ...],
) -> None:
if snapshot is None or snapshot.status != "ready" or not diagnostics:
self._clear_diagnostic_line_highlight()
return
diagnostic = self._selected_diagnostic()
if diagnostic is None:
self._clear_diagnostic_line_highlight()
return
setter = getattr(self.editor, "set_diagnostic_line_highlight", None)
if callable(setter):
setter(diagnostic, generation=snapshot.generation)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep highlight selection tied to the supplied snapshot.

_sync_selected_diagnostic_highlight() accepts a snapshot and diagnostics tuple, but Line 2616 re-reads the current snapshot through _selected_diagnostic(). Use the passed tuple so the diagnostic and generation=snapshot.generation cannot drift across async refresh boundaries.

Suggested fix
-        diagnostic = self._selected_diagnostic()
-        if diagnostic is None:
-            self._clear_diagnostic_line_highlight()
-            return
+        idx = max(0, min(len(diagnostics) - 1, self.selected_idx))
+        self.selected_idx = idx
+        diagnostic = diagnostics[idx]
         setter = getattr(self.editor, "set_diagnostic_line_highlight", None)
         if callable(setter):
             setter(diagnostic, generation=snapshot.generation)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _sync_selected_diagnostic_highlight(
self,
snapshot: DiagnosticsSnapshot | None,
diagnostics: tuple[Diagnostic, ...],
) -> None:
if snapshot is None or snapshot.status != "ready" or not diagnostics:
self._clear_diagnostic_line_highlight()
return
diagnostic = self._selected_diagnostic()
if diagnostic is None:
self._clear_diagnostic_line_highlight()
return
setter = getattr(self.editor, "set_diagnostic_line_highlight", None)
if callable(setter):
setter(diagnostic, generation=snapshot.generation)
def _sync_selected_diagnostic_highlight(
self,
snapshot: DiagnosticsSnapshot | None,
diagnostics: tuple[Diagnostic, ...],
) -> None:
if snapshot is None or snapshot.status != "ready" or not diagnostics:
self._clear_diagnostic_line_highlight()
return
idx = max(0, min(len(diagnostics) - 1, self.selected_idx))
self.selected_idx = idx
diagnostic = diagnostics[idx]
setter = getattr(self.editor, "set_diagnostic_line_highlight", None)
if callable(setter):
setter(diagnostic, generation=snapshot.generation)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/ui/panels.py` around lines 2608 - 2622, Keep the selected diagnostic
highlight tied to the snapshot passed into _sync_selected_diagnostic_highlight()
instead of re-reading state via _selected_diagnostic(). Use the supplied
diagnostics tuple to resolve the selected Diagnostic, then pass that same object
to set_diagnostic_line_highlight with generation=snapshot.generation so the
highlight cannot drift across async refreshes.

Comment on lines +410 to +413
def test_pass_status_message_uses_success_style_hook() -> None:
editor = FakeEditor()
editor.status_message = "Diagnostics: PASS — no issues found."
editor.filename = "/tmp/f4_bad.py"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer tmp_path over hardcoded /tmp/... paths for consistency.

These three tests use literal "/tmp/f4_bad.py" / "/tmp/example.py", which the static analyzers flag as publicly-writable temp paths. The risk is low here since neither test touches disk (goto_diagnostic is faked and _draw_status_bar only renders), but the rest of this file consistently uses the tmp_path fixture. Aligning these keeps the suite uniform and silences the SonarCloud/ast-grep findings.

Also applies to: 426-452

🧰 Tools
🪛 ast-grep (0.44.0)

[info] 412-412: Do not hardcode temporary file or directory names
Context: "/tmp/f4_bad.py"
Note: [CWE-377] Insecure Temporary File.

(hardcoded-tmp-file)

🪛 GitHub Check: SonarCloud Code Analysis

[failure] 413-413: Make sure publicly writable directories are used safely here.

See more on https://sonarcloud.io/project/issues?id=SSobol77_ecli&issues=AZ8ByvedVhkxC5OQdjPF&open=AZ8ByvedVhkxC5OQdjPF&pullRequest=117

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ui/test_diagnostics_panel.py` around lines 410 - 413, The tests in this
block are still using literal temp filenames like /tmp/f4_bad.py and
/tmp/example.py instead of the existing tmp_path fixture. Update the affected
test setup in test_pass_status_message_uses_success_style_hook and the other
matching tests in this file to derive filenames from tmp_path, keeping the same
fake-editor behavior while removing hardcoded publicly-writable paths. Use the
same pattern already used elsewhere in tests/ui/test_diagnostics_panel.py so the
suite stays consistent.

Source: Linters/SAST tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant