Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docusaurus/docs/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Blocking hooks reject actions or force fixes before they land. Non-blocking hook

| Hook | Description |
|------|-------------|
| `file_checker.py` | Linting (ruff/ESLint/go vet) and TDD enforcement — warns when editing without a failing test |
| `file_checker.py` | Linting (ruff/ESLint/go vet) plus a C# `dotnet format` whitespace check, and TDD enforcement — warns when editing without a failing test |
| `context_monitor.py` | Tracks context usage 0–100%, warns as compaction approaches |
| Memory observer | Saves decisions, discoveries, and bugfixes to persistent memory (async) |

Expand Down
15 changes: 14 additions & 1 deletion docs/docusaurus/docs/features/language-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: Real-time diagnostics, go-to-definition, and find-references via au
# Language Servers

:::warning Claude Code only
Language Server integration requires Claude Code's LSP support. Codex CLI does not have an equivalent LSP integration. On Codex, the `file_checker.py` hook still provides linting and type-checking via the underlying CLI tools (ruff, basedpyright, ESLint, go vet) — but without real-time editor-style diagnostics, hover docs, or go-to-definition.
Language Server integration requires Claude Code's LSP support. Codex CLI does not have an equivalent LSP integration. On Codex, the `file_checker.py` hook still provides linting and type-checking via the underlying CLI tools (ruff, basedpyright, ESLint, go vet) — plus a single-file `dotnet format` whitespace check for C# — but without real-time editor-style diagnostics, hover docs, or go-to-definition.
:::

Real-time diagnostics and go-to-definition for Claude Code, auto-installed and configured.
Expand Down Expand Up @@ -43,6 +43,19 @@ Language servers give Claude Code real-time diagnostics, type information, and g

> Requires Go modules. Respects GOPATH and module proxy settings.

## C# — csharp-ls (opt-in)

Unlike the servers above, the C# language server is **not auto-installed** — it needs the .NET SDK, which Pilot does not ship. .NET developers enable it explicitly, so non-.NET users aren't burdened with a .NET toolchain.

[C# LSP](https://claude.com/plugins/csharp-lsp) is the Roslyn-based `csharp-ls` server recommended by Claude. It provides real-time diagnostics, go-to-definition, find-references, hover, and `.editorconfig`-aware formatting for `.cs` files across .NET Core/Framework and multi-project solutions.

**Enable it:**

1. Install the plugin from the [C# LSP plugin page](https://claude.com/plugins/csharp-lsp).
2. Install the server: `dotnet tool install --global csharp-ls` (or `brew install csharp-ls`). Requires .NET SDK 6.0+.

> With the LSP active you get the real-time compile diagnostics that the `file_checker.py` hook does not provide for C# — the hook runs a single-file `dotnet format` check only. Compile errors otherwise surface when you run `dotnet test`.

:::tip Add custom language servers
Add custom language servers via `.lsp.json` in your project root. Each language key maps to its server configuration:

Expand Down
4 changes: 3 additions & 1 deletion docs/docusaurus/docs/features/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: Production-tested rules and standards loaded into every Claude Code

Production-tested best practices loaded into every session.

Rules load automatically at session start — enforced standards, not suggestions. Pilot ships 10 built-in rules plus 5 coding standards activated by file type.
Rules load automatically at session start — enforced standards, not suggestions. Pilot ships 10 built-in rules plus 7 coding standards activated by file type.

- **Claude Code:** rules in `~/.claude/rules/` (global) and `.claude/rules/` (project). Project rules take precedence.
- **Codex:** rules delivered via `~/.codex/AGENTS.md`, adapted from the same source. Custom rules work the same way.
Expand Down Expand Up @@ -42,7 +42,9 @@ Run `/setup-rules` (or `$setup-rules` on Codex) to generate project-specific rul
| Python | `*.py` | uv, pytest, ruff, basedpyright, type hints |
| TypeScript | `*.ts, *.tsx, *.js, *.jsx` | npm/pnpm, Jest, ESLint, Prettier, React patterns |
| Go | `*.go` | Modules, testing, formatting, error handling |
| .NET | `*.cs, *.csproj, *.sln` | dotnet CLI, format gate, nullable, analyzers, test traits |
| Frontend | `*.tsx, *.jsx, *.html, *.vue, *.css` | Components, CSS, accessibility, responsive design |
| Blazor | `*.razor, *.razor.css, *.razor.cs` | Components, CSS isolation, render modes, lifecycle |
| Backend | `**/models/**, **/routes/**, **/api/**` | API design, data models, query optimization, migrations |

:::tip Custom rules
Expand Down
3 changes: 2 additions & 1 deletion pilot/hooks/_checkers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Language-specific file checkers."""

from _checkers.dotnet import check_dotnet
from _checkers.go import check_go
from _checkers.python import check_python
from _checkers.typescript import check_typescript

__all__ = ["check_go", "check_python", "check_typescript"]
__all__ = ["check_dotnet", "check_go", "check_python", "check_typescript"]
181 changes: 181 additions & 0 deletions pilot/hooks/_checkers/dotnet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Dotnet file checker — single-file dotnet format check (no per-edit build)."""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from pathlib import Path

from _lib.util import BLUE, NC, check_file_length

from _checkers.tdd import is_dotnet_test_project_name, should_skip

DOTNET_EXTENSIONS = {".cs", ".razor"}
DEBUG = os.environ.get("HOOK_DEBUG", "").lower() == "true"

# `dotnet format --verify-no-changes` returns this (CheckFailedExitCode) when a
# file needs formatting; 1 (UnhandledException) and 3 (MSBuild-not-found) mean
# the tool itself failed. See dotnet/sdk FormatCommandCommon.cs.
_FORMAT_CHANGES_NEEDED = 2


def debug_log(message: str) -> None:
"""Print debug message if enabled."""
if DEBUG:
print(f"{BLUE}[DEBUG]{NC} {message}", file=sys.stderr)


def find_project_root(file_path: Path) -> Path | None:
"""Find nearest directory with a .csproj or .sln file."""
current = file_path.parent
depth = 0
while current != current.parent:
if list(current.glob("*.csproj")) or list(current.glob("*.sln")):
return current
current = current.parent
depth += 1
if depth > 20:
break
return None


def check_dotnet(file_path: Path) -> tuple[int, str]:
"""Check .NET file with a single-file `dotnet format`. Returns (0, reason)."""
# Skip build output / generated / vendored dirs (bin, obj, generated, …) —
# shares the TDD checker's skip list so the format and TDD paths agree.
if should_skip(str(file_path)):
return 0, ""

stem = file_path.stem
if stem.endswith("Tests") or stem.endswith("Test"):
return 0, ""
# Skip files inside a .NET test project (MyApp.Tests, IntegrationTests, …).
# Same predicate as _find_dotnet_test_dirs so skip and discovery agree; a
# path-segment match avoids over-skipping siblings like MyApp.TestData.
if any(is_dotnet_test_project_name(part) for part in file_path.parts):
return 0, ""

length_warning = check_file_length(file_path)

# `dotnet format whitespace` (folder mode) only loads C# documents — a
# `.razor` --include matches nothing, so skip the subprocess entirely and
# keep just the length check for components.
if file_path.suffix != ".cs":
return 0, length_warning

project_root = find_project_root(file_path)
if not project_root:
return 0, length_warning

dotnet_bin = shutil.which("dotnet")
if not dotnet_bin:
return 0, length_warning

has_issues, results = _run_dotnet_format(dotnet_bin, project_root, file_path)

if has_issues:
parts = []
for tool_name, (count, _) in results.items():
label = "issue" if count == 1 else "issues"
parts.append(f"{count} {tool_name} {label}")
reason = f"Dotnet: {', '.join(parts)} in {file_path.name}"
details = _format_dotnet_issues(file_path, results)
if details:
reason = f"{reason}\n{details}"
if length_warning:
reason = f"{reason}\n{length_warning}"
return 0, reason

return 0, length_warning


def _run_dotnet_format(
dotnet_bin: str,
project_root: Path,
file_path: Path,
) -> tuple[bool, dict[str, tuple]]:
"""Run `dotnet format whitespace --folder` scoped to the edited file and collect results."""
has_issues = False
results: dict[str, tuple] = {}
try:
# `whitespace --folder` skips the MSBuild project load, restore, and analyzer
# compilation (the dominant per-edit cost) while still applying .editorconfig
# whitespace rules. Style/analyzer feedback is deferred to the LSP and
# `dotnet build` / `dotnet test`.
cmd = [
dotnet_bin,
"format",
"whitespace",
str(project_root),
"--folder",
"--verify-no-changes",
"--verbosity",
"q",
]

try:
include_path = file_path.relative_to(project_root)
except ValueError:
include_path = file_path
cmd.extend(["--include", str(include_path)])

debug_log(f"Running: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
cwd=project_root,
timeout=60,
)
debug_log(f"Format exit code: {result.returncode}")

# Exit 2 = files need formatting. Any other non-zero code is a real tool
# failure (1 = unhandled exception, 3 = MSBuild not found); swallow it
# like a timeout instead of mislabeling its error text as whitespace issues.
if result.returncode == _FORMAT_CHANGES_NEEDED:
output = result.stdout + result.stderr
# Collect filenames that need formatting
format_lines = [
line.strip()
for line in output.splitlines()
if line.strip() and not line.strip().startswith("The dotnet format command")
]
if format_lines:
has_issues = True
results["format"] = (len(format_lines), format_lines)
else:
# Changes needed but no specific lines (quiet verbosity) — still report.
has_issues = True
results["format"] = (1, ["Code formatting issues detected"])
elif result.returncode != 0:
debug_log(f"dotnet format failed (exit {result.returncode}); not reporting as issues")
except subprocess.TimeoutExpired:
debug_log("Format check timed out")
except (OSError, subprocess.SubprocessError) as exc:
debug_log(f"Format check failed to run: {exc}")
return has_issues, results


def _format_dotnet_issues(file_path: Path, results: dict[str, tuple]) -> str:
"""Format .NET diagnostic issues as plain text."""
lines: list[str] = []
try:
display_path = file_path.relative_to(Path.cwd())
except ValueError:
display_path = file_path
lines.append(f".NET Issues found in: {display_path}")

if "format" in results:
count, format_lines = results["format"]
plural = "issue" if count == 1 else "issues"
lines.append(f"Format: {count} whitespace {plural} (run `dotnet format`)")
for line in format_lines[:10]:
lines.append(f" {line}")
if count > 10:
lines.append(f" ... and {count - 10} more")

lines.append("Fix .NET issues above before continuing")
return "\n".join(lines)
Loading