diff --git a/docs/docusaurus/docs/features/hooks.md b/docs/docusaurus/docs/features/hooks.md index 690bce89..69bc7fbc 100644 --- a/docs/docusaurus/docs/features/hooks.md +++ b/docs/docusaurus/docs/features/hooks.md @@ -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) | diff --git a/docs/docusaurus/docs/features/language-servers.md b/docs/docusaurus/docs/features/language-servers.md index af54b8d9..3f679118 100644 --- a/docs/docusaurus/docs/features/language-servers.md +++ b/docs/docusaurus/docs/features/language-servers.md @@ -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. @@ -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: diff --git a/docs/docusaurus/docs/features/rules.md b/docs/docusaurus/docs/features/rules.md index b28956c4..6aecf95e 100644 --- a/docs/docusaurus/docs/features/rules.md +++ b/docs/docusaurus/docs/features/rules.md @@ -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. @@ -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 diff --git a/pilot/hooks/_checkers/__init__.py b/pilot/hooks/_checkers/__init__.py index 7ee89b2d..e1949996 100644 --- a/pilot/hooks/_checkers/__init__.py +++ b/pilot/hooks/_checkers/__init__.py @@ -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"] diff --git a/pilot/hooks/_checkers/dotnet.py b/pilot/hooks/_checkers/dotnet.py new file mode 100644 index 00000000..b54d7ceb --- /dev/null +++ b/pilot/hooks/_checkers/dotnet.py @@ -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) diff --git a/pilot/hooks/_checkers/tdd.py b/pilot/hooks/_checkers/tdd.py index 0af535de..7081cb59 100755 --- a/pilot/hooks/_checkers/tdd.py +++ b/pilot/hooks/_checkers/tdd.py @@ -5,6 +5,7 @@ from __future__ import annotations +import functools import json import re import sys @@ -27,6 +28,13 @@ ".env", ".env.example", ".sql", + ".dll", + ".exe", + ".pdb", + ".csproj", + ".sln", + ".props", + ".targets", ] EXCLUDED_DIRS = [ @@ -52,6 +60,10 @@ "/__pycache__/", ] +# .NET build output — skipped only for .NET source, since other ecosystems +# legitimately keep hand-written source under bin/ (entry-point scripts, etc.). +DOTNET_BUILD_DIRS = ["/bin/", "/obj/"] + def should_skip(file_path: str) -> bool: """Check if file should be skipped based on extension or directory.""" @@ -67,6 +79,9 @@ def should_skip(file_path: str) -> bool: if excluded_dir in file_path: return True + if path.suffix in (".cs", ".razor") and any(d in file_path for d in DOTNET_BUILD_DIRS): + return True + return False @@ -86,6 +101,11 @@ def is_test_file(file_path: str) -> bool: if name.endswith("_test.go"): return True + if name.endswith(".cs"): + stem = path.stem + if stem.endswith("Tests") or stem.endswith("Test"): + return True + return False @@ -138,6 +158,55 @@ def _find_test_dirs(start: Path) -> list[Path]: return dirs +def is_dotnet_test_project_name(name: str) -> bool: + """True if a directory name follows a .NET test-project convention. + + Matches the dotted convention (``MyApp.Tests``) or a PascalCase boundary + (``IntegrationTests``, ``FooTest``). Requiring the dot or a capital 'T' + avoids matching words that merely end in "test" (latest, contest, greatest). + Shared by the format checker's skip and test-dir discovery so the two agree. + """ + return name.lower().endswith((".tests", ".test")) or name.endswith(("Tests", "Test")) + + +@functools.lru_cache(maxsize=128) +def _find_dotnet_test_dirs(start: Path) -> list[Path]: + """Walk up from start to find common .NET test directories/projects. + + Memoized: both callers per edit (``has_dotnet_test_file`` and + ``has_test_importing_module_dotnet``) pass the same start dir, so the second + lookup is a cache hit instead of a second full iterdir walk. The hook runs as + a short-lived process, so the cache only dedupes within a single run (no + cross-edit staleness). Callers must not mutate the returned list. + """ + dirs: list[Path] = [] + current = start + seen: set[Path] = set() + + for _ in range(15): + for name in ("tests", "test", "Tests"): + candidate = current / name + if candidate.is_dir() and candidate not in seen: + dirs.append(candidate) + seen.add(candidate) + + try: + for child in current.iterdir(): + if not child.is_dir() or child in seen: + continue + if is_dotnet_test_project_name(child.name): + dirs.append(child) + seen.add(child) + except OSError: + pass + + if current.parent == current: + break + current = current.parent + + return dirs + + def _search_test_dirs(test_dirs: list[Path], base_name: str, extensions: list[str]) -> bool: """Search test directories for files matching base_name with any of the given extensions.""" patterns = [f"**/{base_name}{ext}" for ext in extensions] @@ -300,6 +369,188 @@ def has_go_test_file(impl_path: str) -> bool: return _search_test_dirs(test_dirs, base_name, ["_test.go"]) +def has_dotnet_test_file(impl_path: str) -> bool: + """Check if corresponding .NET test file exists (sibling or in test dirs).""" + path = Path(impl_path) + + if not path.name.endswith((".cs", ".razor")): + return False + + base_name = path.stem + if not base_name: + return False + + sibling_names = [f"{base_name}Tests.cs", f"{base_name}Test.cs"] + for name in sibling_names: + if (path.parent / name).exists(): + return True + + test_dirs = _find_dotnet_test_dirs(path.parent) + return _search_test_dirs(test_dirs, "", sibling_names) + + +def has_test_importing_module_dotnet(impl_path: str) -> bool: + """Return True if any nearby .NET test references the edited module/component.""" + path = Path(impl_path) + if not path.name.endswith((".cs", ".razor")): + return False + + module_name = path.stem + if not module_name: + return False + + test_dirs = _find_dotnet_test_dirs(path.parent) + if not test_dirs: + return False + + symbol_re = re.compile(rf"\b{re.escape(module_name)}\b") + test_attr_re = re.compile(r"\[(Fact|Theory|Test|TestMethod|TestCase|TestCaseSource)\b") + + for test_dir in test_dirs: + for test_file in test_dir.glob("**/*.cs"): + try: + src = test_file.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + if not test_attr_re.search(src): + continue + if symbol_re.search(src): + return True + + return False + + +def _strip_cs_comments_and_strings(src: str) -> str: + """Remove C# comments and string/char literals. + + Braces, '=>', and keywords inside comments or string/char literals must not + drive logic detection. Verbatim (@"..."), interpolated ($"..."), and regular + strings are all reduced to empty content. + """ + out: list[str] = [] + i, n = 0, len(src) + while i < n: + c = src[i] + # Line comment // ... (also covers /// XML doc) + if c == "/" and i + 1 < n and src[i + 1] == "/": + j = src.find("\n", i) + if j == -1: + break + i = j + continue + # Block comment /* ... */ + if c == "/" and i + 1 < n and src[i + 1] == "*": + j = src.find("*/", i + 2) + i = n if j == -1 else j + 2 + continue + # Verbatim string @"..." with doubled "" escapes + if c == "@" and i + 1 < n and src[i + 1] == '"': + i += 2 + while i < n: + if src[i] == '"': + if i + 1 < n and src[i + 1] == '"': + i += 2 + continue + i += 1 + break + i += 1 + continue + # Regular / interpolated string "..." (the $ before it falls through to here) + if c == '"': + i += 1 + while i < n: + if src[i] == "\\": + i += 2 + continue + if src[i] == '"': + i += 1 + break + i += 1 + continue + # Char literal '.' + if c == "'": + i += 1 + while i < n: + if src[i] == "\\": + i += 2 + continue + if src[i] == "'": + i += 1 + break + i += 1 + continue + out.append(c) + i += 1 + return "".join(out) + + +# Reserved statement keywords whose presence implies executable logic. All are +# reserved (or contextual) C# keywords, so they cannot appear as PascalCase type +# or member names — matching lowercase \b…\b is safe against identifiers. +_CS_LOGIC_KEYWORDS = re.compile(r"\b(return|if|for|foreach|while|switch|try|throw|await|yield|goto|do)\b") +_CS_TYPE_KEYWORDS = re.compile(r"\b(interface|enum|record|class|struct)\b") +# A type header's own primary-constructor parameter list, so `record Foo(...) { props }` +# and `class Foo(...)` are not misread as a method/constructor body. +_CS_PRIMARY_CTOR = re.compile(r"\b(?:record|class|struct|interface)\s+\w+(?:\s*<[^>]*>)?\s*\([^)]*\)") +# An initializer's constructor/factory call (`= new(...)`, `= new T(...)`, `= Make(...)`), +# so an object/collection initializer brace (`= new() { ... }`) is not misread as a +# method/ctor body by the ')' '{' check below. +_CS_INITIALIZER_CALL = re.compile(r"=\s*(?:new\b\s*)?[\w.]*(?:\s*<[^>]*>)?\s*\([^)]*\)") + + +def is_dotnet_logic_free(impl_path: str) -> bool: + """Conservatively report whether a C# file is provably free of testable logic. + + True ⇒ the file is only interfaces (signatures), enums, positional records, or + POCO/DTO classes whose members are auto-properties/fields/constants — safe to + skip the TDD reminder. Any sign of executable logic, or any inability to read + the file, returns False (keep enforcing). `.razor` is never treated as logic-free. + """ + # Only plain .cs. .razor (and components in general) carry logic in markup/@code. + if not impl_path.endswith(".cs"): + return False + + try: + raw = Path(impl_path).read_text(encoding="utf-8", errors="ignore") + except OSError: + return False + + code = _strip_cs_comments_and_strings(raw) + + # Must contain at least one type declaration; otherwise (e.g. assembly-info, + # top-level statements) treat as ambiguous and enforce. + if not _CS_TYPE_KEYWORDS.search(code): + return False + + # Expression-bodied member / lambda. + if "=>" in code: + return False + + # Statement keyword anywhere ⇒ executable logic. + if _CS_LOGIC_KEYWORDS.search(code): + return False + + # Manual accessor body (get/set/init/add/remove { … }) — also catches default + # interface methods written as accessors and explicit event accessors, which + # carry executable logic. Auto-properties use `get;` and never match. + if re.search(r"\b(?:get|set|init|add|remove)\b\s*\{", code): + return False + + # Method / constructor / control body: a ')' followed by '{', after stripping the + # type header's primary-constructor list AND any initializer call. An optional + # generic constraint (`where T : class`) may sit between ')' and '{' on a + # constrained generic method — it must not let a real body slip through. + # Field/property initializers (`= new() { ... }`, `= Factory()`) are deliberately + # NOT treated as own-logic — only method/accessor/ctor bodies are. Without the + # initializer strip, an idiomatic DTO with a braced collection/object initializer + # (`= new() { 1, 2 }`) would false-match ')' '{' and be wrongly enforced. + body_code = _CS_INITIALIZER_CALL.sub(" ", _CS_PRIMARY_CTOR.sub(" ", code)) + if re.search(r"\)\s*(?:where\b[^{}]*)?\{", body_code): + return False + + return True + + def _is_import_line(line: str) -> bool: """Check if a line is part of an import statement.""" if line.startswith(("import ", "from ")): @@ -435,4 +686,21 @@ def run_tdd_enforcer() -> int: "See pilot/rules/testing.md § Test Parsimony.", ) + if file_path.endswith((".cs", ".razor")): + if has_dotnet_test_file(file_path): + return 0 + + if has_test_importing_module_dotnet(file_path): + return 0 + + if is_dotnet_logic_free(file_path): + return 0 + + return warn( + "No test covers this module's behaviour", + "Consider whether existing tests cover this behaviour. " + "If not, add a test for the new behaviour — not necessarily a new file. " + "See pilot/rules/testing.md § Test Parsimony.", + ) + return 0 diff --git a/pilot/hooks/file_checker.py b/pilot/hooks/file_checker.py index 4efaf748..61a4bfda 100644 --- a/pilot/hooks/file_checker.py +++ b/pilot/hooks/file_checker.py @@ -15,13 +15,17 @@ sys.path.insert(0, str(Path(__file__).parent)) from _checkers.charset import check_charset +from _checkers.dotnet import DOTNET_EXTENSIONS, check_dotnet from _checkers.go import check_go from _checkers.python import check_python from _checkers.tdd import ( + has_dotnet_test_file, has_go_test_file, has_python_test_file, has_related_failing_test, + has_test_importing_module_dotnet, has_typescript_test_file, + is_dotnet_logic_free, is_test_file, is_trivial_edit, should_skip, @@ -65,6 +69,16 @@ def _tdd_check(tool_name: str, tool_input: dict, file_path: str) -> str: base_name = Path(file_path).stem return f"TDD Reminder: No test file found\n Consider creating {base_name}_test.go first." + if file_path.endswith((".cs", ".razor")): + if has_dotnet_test_file(file_path): + return "" + if has_test_importing_module_dotnet(file_path): + return "" + if is_dotnet_logic_free(file_path): + return "" + class_name = Path(file_path).stem + return f"TDD Reminder: No test file found for '{class_name}'\n Consider creating {class_name}Tests.cs first." + return "" @@ -195,6 +209,8 @@ def main() -> int: _, file_reason = check_typescript(target_file) elif target_file.suffix == ".go": _, file_reason = check_go(target_file) + elif target_file.suffix in DOTNET_EXTENSIONS: + _, file_reason = check_dotnet(target_file) tdd_reason = _tdd_check(tool_name, tool_input, file_path_str) charset_reason = check_charset(target_file, changed_text.get(file_path_str)) diff --git a/pilot/hooks/tests/test_dotnet_checker.py b/pilot/hooks/tests/test_dotnet_checker.py new file mode 100644 index 00000000..67b5cb97 --- /dev/null +++ b/pilot/hooks/tests/test_dotnet_checker.py @@ -0,0 +1,244 @@ +"""Tests for the .NET file checker (dotnet format, single-file scoped; no per-edit build).""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from _checkers.dotnet import check_dotnet + + +def _which(name: str): + """shutil.which stub: dotnet is present, nothing else.""" + return "/usr/bin/dotnet" if name == "dotnet" else None + + +class TestCheckDotnet: + """Observable behavior of check_dotnet after the single-file optimization.""" + + def test_no_dotnet_binary_skips_checks(self, tmp_path: Path) -> None: + """With a project present but no dotnet on PATH, no subprocess runs.""" + (tmp_path / "App.csproj").write_text("\n") + cs = tmp_path / "Foo.cs" + cs.write_text("namespace App; public class Foo { }\n") + + with ( + patch("_checkers.dotnet.check_file_length", return_value=""), + patch("_checkers.dotnet.shutil.which", return_value=None), + patch("_checkers.dotnet.subprocess.run") as mock_run, + ): + exit_code, reason = check_dotnet(cs) + + assert exit_code == 0 + assert reason == "" + mock_run.assert_not_called() + + def test_test_file_is_skipped(self, tmp_path: Path) -> None: + """A *Tests.cs file returns early without invoking any tool.""" + (tmp_path / "App.csproj").write_text("\n") + cs = tmp_path / "FooTests.cs" + cs.write_text("namespace App.Tests; public class FooTests { }\n") + + with ( + patch("_checkers.dotnet.check_file_length", return_value=""), + patch("_checkers.dotnet.shutil.which", side_effect=_which), + patch("_checkers.dotnet.subprocess.run") as mock_run, + ): + exit_code, reason = check_dotnet(cs) + + assert exit_code == 0 + assert reason == "" + mock_run.assert_not_called() + + def test_format_is_scoped_to_edited_file_and_no_build_runs(self, tmp_path: Path) -> None: + """Only `dotnet format` runs (no build), and it is scoped to the edited file via --include.""" + (tmp_path / "App.csproj").write_text("\n") + src = tmp_path / "src" + src.mkdir() + cs = src / "Foo.cs" + cs.write_text("namespace App; public class Foo { }\n") + + clean = MagicMock(returncode=0, stdout="", stderr="") + + with ( + patch("_checkers.dotnet.check_file_length", return_value=""), + patch("_checkers.dotnet.shutil.which", side_effect=_which), + patch("_checkers.dotnet.subprocess.run", return_value=clean) as mock_run, + ): + check_dotnet(cs) + + commands = [call.args[0] for call in mock_run.call_args_list] + # No build is ever invoked. + assert all("build" not in cmd for cmd in commands), commands + # Exactly one tool runs: dotnet format, scoped to the edited file. + assert len(commands) == 1, commands + fmt = commands[0] + assert "format" in fmt + # Fast path: whitespace subcommand in folder mode skips the MSBuild project load. + assert "whitespace" in fmt + assert "--folder" in fmt + assert "--include" in fmt + # The path passed to --include is the edited file relative to the project root. + include_idx = fmt.index("--include") + assert fmt[include_idx + 1] == str(Path("src") / "Foo.cs") + + def test_format_issues_are_reported(self, tmp_path: Path) -> None: + """A non-zero `dotnet format --verify-no-changes` surfaces a formatting reason.""" + (tmp_path / "App.csproj").write_text("\n") + cs = tmp_path / "Foo.cs" + cs.write_text("namespace App; public class Foo { }\n") + + needs_format = MagicMock( + returncode=2, + stdout="Formatted code file 'Foo.cs'.\n", + stderr="", + ) + + with ( + patch("_checkers.dotnet.check_file_length", return_value=""), + patch("_checkers.dotnet.shutil.which", side_effect=_which), + patch("_checkers.dotnet.subprocess.run", return_value=needs_format), + ): + exit_code, reason = check_dotnet(cs) + + assert exit_code == 0 + assert "format" in reason.lower() + # The run is scoped to one file via --include, so issues are reported as + # whitespace issues, never as a count of "files needing formatting". + assert "files need formatting" not in reason + + def test_file_in_test_project_dir_is_skipped(self, tmp_path: Path) -> None: + """A file under a MyApp.Tests/ project dir is skipped without running any tool.""" + proj = tmp_path / "MyApp.Tests" + proj.mkdir() + (proj / "App.csproj").write_text("\n") + cs = proj / "Foo.cs" + cs.write_text("namespace T; public class Foo { }\n") + + with patch("_checkers.dotnet.subprocess.run") as mock_run: + exit_code, reason = check_dotnet(cs) + + assert exit_code == 0 + assert reason == "" + mock_run.assert_not_called() + + def test_file_in_testdata_dir_is_not_skipped(self, tmp_path: Path) -> None: + """A sibling like MyApp.TestData/ is NOT a test project — its files are still checked.""" + proj = tmp_path / "MyApp.TestData" + proj.mkdir() + (proj / "App.csproj").write_text("\n") + cs = proj / "Foo.cs" + cs.write_text("namespace T; public class Foo { }\n") + + clean = MagicMock(returncode=0, stdout="", stderr="") + with ( + patch("_checkers.dotnet.check_file_length", return_value=""), + patch("_checkers.dotnet.shutil.which", side_effect=_which), + patch("_checkers.dotnet.subprocess.run", return_value=clean) as mock_run, + ): + check_dotnet(cs) + + mock_run.assert_called_once() + + def test_subprocess_oserror_is_handled(self, tmp_path: Path) -> None: + """A subprocess failure (e.g. dotnet vanished) is swallowed — no crash, no false reason.""" + (tmp_path / "App.csproj").write_text("\n") + cs = tmp_path / "Foo.cs" + cs.write_text("namespace App; public class Foo { }\n") + + with ( + patch("_checkers.dotnet.check_file_length", return_value=""), + patch("_checkers.dotnet.shutil.which", side_effect=_which), + patch("_checkers.dotnet.subprocess.run", side_effect=OSError("boom")), + ): + exit_code, reason = check_dotnet(cs) + + assert exit_code == 0 + assert reason == "" + + def test_clean_format_produces_no_reason(self, tmp_path: Path) -> None: + """A clean format run yields no reason.""" + (tmp_path / "App.csproj").write_text("\n") + cs = tmp_path / "Foo.cs" + cs.write_text("namespace App; public class Foo { }\n") + + clean = MagicMock(returncode=0, stdout="", stderr="") + + with ( + patch("_checkers.dotnet.check_file_length", return_value=""), + patch("_checkers.dotnet.shutil.which", side_effect=_which), + patch("_checkers.dotnet.subprocess.run", return_value=clean), + ): + exit_code, reason = check_dotnet(cs) + + assert exit_code == 0 + assert reason == "" + + def test_generated_file_under_obj_is_skipped(self, tmp_path: Path) -> None: + """A generated .cs under obj/ is skipped without formatting (matches the TDD skip).""" + (tmp_path / "App.csproj").write_text("\n") + gen = tmp_path / "obj" / "Debug" / "net8.0" + gen.mkdir(parents=True) + cs = gen / "App.AssemblyInfo.cs" + cs.write_text('[assembly: System.Reflection.AssemblyVersion("1.0")]\n') + + with patch("_checkers.dotnet.subprocess.run") as mock_run: + exit_code, reason = check_dotnet(cs) + + assert exit_code == 0 + assert reason == "" + mock_run.assert_not_called() + + def test_file_in_non_dotted_test_project_is_skipped(self, tmp_path: Path) -> None: + """A file under IntegrationTests/ is skipped — the skip now matches test-dir discovery.""" + proj = tmp_path / "IntegrationTests" + proj.mkdir() + (proj / "App.csproj").write_text("\n") + cs = proj / "Helpers.cs" + cs.write_text("namespace T; public class Helpers { }\n") + + with patch("_checkers.dotnet.subprocess.run") as mock_run: + exit_code, reason = check_dotnet(cs) + + assert exit_code == 0 + assert reason == "" + mock_run.assert_not_called() + + def test_razor_skips_format_but_keeps_length_check(self, tmp_path: Path) -> None: + """`.razor` never spawns dotnet format (no-op in folder mode) but still gets a length check.""" + (tmp_path / "App.csproj").write_text("\n") + razor = tmp_path / "Counter.razor" + razor.write_text("

Counter

\n") + + with ( + patch("_checkers.dotnet.check_file_length", return_value="too long"), + patch("_checkers.dotnet.shutil.which", side_effect=_which), + patch("_checkers.dotnet.subprocess.run") as mock_run, + ): + exit_code, reason = check_dotnet(razor) + + assert exit_code == 0 + assert reason == "too long" + mock_run.assert_not_called() + + def test_tool_error_exit_is_not_reported_as_issues(self, tmp_path: Path) -> None: + """A real dotnet format failure (exit 1, not 2) is swallowed — its error text is not mislabeled.""" + (tmp_path / "App.csproj").write_text("\n") + cs = tmp_path / "Foo.cs" + cs.write_text("namespace App; public class Foo { }\n") + + tool_error = MagicMock( + returncode=1, + stdout="", + stderr="Unhandled exception: could not load .editorconfig\n", + ) + + with ( + patch("_checkers.dotnet.check_file_length", return_value=""), + patch("_checkers.dotnet.shutil.which", side_effect=_which), + patch("_checkers.dotnet.subprocess.run", return_value=tool_error), + ): + exit_code, reason = check_dotnet(cs) + + assert exit_code == 0 + assert reason == "" diff --git a/pilot/hooks/tests/test_file_checker.py b/pilot/hooks/tests/test_file_checker.py index 5f991745..25b8cc0c 100644 --- a/pilot/hooks/tests/test_file_checker.py +++ b/pilot/hooks/tests/test_file_checker.py @@ -6,7 +6,7 @@ import json from unittest.mock import patch -from file_checker import main +from file_checker import _tdd_check, main EM_DASH = "\u2014" @@ -283,3 +283,45 @@ def test_apply_patch_decorative_char_scoped_to_its_own_file(self, tmp_path, caps assert "U+2014" in context # the dirty file's added em-dash is flagged assert "dirty.sh" in context assert "clean.sh" not in context # the clean file is NOT falsely flagged + + +class TestDotnetTddSuppression: + """_tdd_check (the live path) suppresses reminders for logic-free C#, enforces otherwise. + + Each fixture lives in an isolated ``tmp_path`` with no accompanying ``*Tests.cs``, + so ``has_dotnet_test_file``'s sibling/test-dir scan intentionally finds nothing and + returns False — exercising the detector path under test, not real external I/O. + """ + + def _check(self, file_path: str) -> str: + return _tdd_check("Write", {"file_path": file_path}, file_path) + + def test_logic_free_cs_file_emits_no_reminder(self, tmp_path): + cs = tmp_path / "PersonDto.cs" + cs.write_text("namespace App;\npublic record PersonDto(string Name, int Age);\n") + assert self._check(str(cs)) == "" + + def test_cs_file_with_method_body_emits_reminder(self, tmp_path): + cs = tmp_path / "Calc.cs" + cs.write_text("namespace App;\npublic class Calc\n{\n public int Add(int a, int b)\n {\n return a + b;\n }\n}\n") + assert "TDD Reminder" in self._check(str(cs)) + + def test_razor_file_always_emits_reminder(self, tmp_path): + razor = tmp_path / "Counter.razor" + razor.write_text("

Counter

\n") + assert "TDD Reminder" in self._check(str(razor)) + + def test_integration_test_importing_module_suppresses_reminder(self, tmp_path): + """A nearby test that references the module counts as coverage (parsimony), + even without a sibling *Tests.cs — the live hook must honour it.""" + src = tmp_path / "src" + src.mkdir() + impl = src / "Order.cs" + impl.write_text("namespace App;\npublic class Order\n{\n public int Total() { return 1; }\n}\n") + tests = tmp_path / "tests" + tests.mkdir() + (tests / "CheckoutFlowTests.cs").write_text( + "using Xunit;\nnamespace T;\n" + "public class CheckoutFlowTests\n{\n [Fact] public void Pays() { var o = new Order(); }\n}\n" + ) + assert self._check(str(impl)) == "" diff --git a/pilot/hooks/tests/test_tdd_enforcer.py b/pilot/hooks/tests/test_tdd_enforcer.py index 63805da4..9d909752 100644 --- a/pilot/hooks/tests/test_tdd_enforcer.py +++ b/pilot/hooks/tests/test_tdd_enforcer.py @@ -8,6 +8,7 @@ from pathlib import Path from _checkers.tdd import ( + _find_dotnet_test_dirs, _find_test_dirs, _pascal_to_kebab, _search_test_dirs, @@ -16,6 +17,7 @@ has_test_importing_module, has_test_importing_module_ts, has_typescript_test_file, + is_dotnet_logic_free, is_test_file, is_trivial_edit, run_tdd_enforcer, @@ -44,6 +46,14 @@ def test_does_not_skip_source_files(self): assert should_skip("/project/src/app.ts") is False assert should_skip("/project/main.go") is False + def test_skips_dotnet_build_output_only_for_cs(self): + assert should_skip("/project/obj/Debug/net8.0/App.AssemblyInfo.cs") is True + assert should_skip("/project/bin/Release/net8.0/Foo.g.cs") is True + + def test_does_not_skip_non_dotnet_bin_source(self): + assert should_skip("/project/bin/cli.py") is False + assert should_skip("/project/src/bin/run.ts") is False + class TestIsTestFile: def test_python_test_prefix(self): @@ -563,3 +573,231 @@ def test_python_warning_references_parsimony_not_create_test_file(self, capsys, data = json.loads(captured.out) assert "Consider creating test_" not in data["reason"] assert "Test Parsimony" in data["reason"] + + +class TestIsDotnetLogicFree: + """Conservative detector: True only for provably logic-free C# (skip TDD); enforce on any doubt.""" + + def _write(self, tmp_path: Path, name: str, body: str) -> str: + f = tmp_path / name + f.write_text(body) + return str(f) + + # --- True: provably logic-free (skip the TDD reminder) --- + + def test_interface_signatures_only_is_logic_free(self, tmp_path: Path): + path = self._write( + tmp_path, + "IRepository.cs", + "namespace App;\npublic interface IRepository\n{\n Task CountAsync();\n string Name { get; }\n}\n", + ) + assert is_dotnet_logic_free(path) is True + + def test_enum_only_is_logic_free(self, tmp_path: Path): + path = self._write(tmp_path, "Status.cs", "namespace App;\npublic enum Status { Active, Disabled, Pending }\n") + assert is_dotnet_logic_free(path) is True + + def test_positional_record_is_logic_free(self, tmp_path: Path): + path = self._write(tmp_path, "PersonDto.cs", "namespace App;\npublic record PersonDto(string First, int Age);\n") + assert is_dotnet_logic_free(path) is True + + def test_poco_auto_properties_and_fields_is_logic_free(self, tmp_path: Path): + body = ( + "namespace App;\n" + "public class Order\n" + "{\n" + " public const int MaxItems = 100;\n" + " public int Id { get; set; }\n" + " public string Name { get; init; }\n" + " private readonly string _tag;\n" + "}\n" + ) + path = self._write(tmp_path, "Order.cs", body) + assert is_dotnet_logic_free(path) is True + + def test_poco_with_collection_initializer_is_logic_free(self, tmp_path: Path): + """Field/property initializers (`= new()`, `= Factory()`) are not own-logic — DTO stays logic-free.""" + body = ( + "namespace App;\n" + "public class Bag\n" + "{\n" + " public List Tags { get; set; } = new();\n" + " private static readonly int _max = Compute();\n" + "}\n" + ) + path = self._write(tmp_path, "Bag.cs", body) + assert is_dotnet_logic_free(path) is True + + def test_poco_with_braced_initializer_is_logic_free(self, tmp_path: Path): + """Braced collection/object initializers (`= new() { ... }`) are not own-logic. + + The `)` `{` of `new() {` must not be misread as a method body — these are the + most common DTO initializer idioms and must stay logic-free. + """ + body = ( + "namespace App;\n" + "public class Bag\n" + "{\n" + " public List Xs { get; set; } = new() { 1, 2, 3 };\n" + " public List Ys = new List() { 4 };\n" + " public Inner I { get; } = new() { A = 1 };\n" + "}\n" + ) + path = self._write(tmp_path, "Bag.cs", body) + assert is_dotnet_logic_free(path) is True + + def test_method_constructing_braced_initializer_still_enforces(self, tmp_path: Path): + """A method body remains logic even when it assigns a braced initializer.""" + body = ( + "namespace App;\n" + "public class Init\n" + "{\n" + " public List Items { get; set; }\n" + " public void Setup() { Items = new() { 1 }; }\n" + "}\n" + ) + path = self._write(tmp_path, "Init.cs", body) + assert is_dotnet_logic_free(path) is False + + def test_xml_doc_comment_with_example_code_is_still_logic_free(self, tmp_path: Path): + """Comment stripping: example code in /// must not force enforcement on a pure DTO.""" + body = ( + "namespace App;\n" + "/// A point.\n" + "/// if (x) { return Foo(); } => bar\n" + "public record Point(int X, int Y);\n" + ) + path = self._write(tmp_path, "Point.cs", body) + assert is_dotnet_logic_free(path) is True + + def test_string_literal_does_not_force_skip_when_real_logic_present(self, tmp_path: Path): + """A method body is real logic even if a string literal contains '}' — enforce.""" + body = ( + 'namespace App;\n' + 'public class Greeter\n' + '{\n' + ' public string Hi() { return "}"; }\n' + '}\n' + ) + path = self._write(tmp_path, "Greeter.cs", body) + assert is_dotnet_logic_free(path) is False + + # --- False: any logic signal keeps enforcement --- + + def test_class_with_method_body_enforces(self, tmp_path: Path): + body = "namespace App;\npublic class Calc\n{\n public int Add(int a, int b)\n {\n return a + b;\n }\n}\n" + path = self._write(tmp_path, "Calc.cs", body) + assert is_dotnet_logic_free(path) is False + + def test_expression_bodied_property_over_backing_field_enforces(self, tmp_path: Path): + body = ( + "namespace App;\n" + "public class Name\n" + "{\n" + " private string _name;\n" + " public string Value => _name;\n" + "}\n" + ) + path = self._write(tmp_path, "Name.cs", body) + assert is_dotnet_logic_free(path) is False + + def test_manual_get_accessor_body_enforces(self, tmp_path: Path): + body = ( + "namespace App;\n" + "public class Temp\n" + "{\n" + " private int _c;\n" + " public int Celsius { get { return _c; } set { _c = value; } }\n" + "}\n" + ) + path = self._write(tmp_path, "Temp.cs", body) + assert is_dotnet_logic_free(path) is False + + def test_constructor_body_enforces(self, tmp_path: Path): + body = ( + "namespace App;\n" + "public class Widget\n" + "{\n" + " public int Id { get; set; }\n" + " public Widget(int id)\n" + " {\n" + " Id = id;\n" + " }\n" + "}\n" + ) + path = self._write(tmp_path, "Widget.cs", body) + assert is_dotnet_logic_free(path) is False + + def test_default_interface_method_with_body_enforces(self, tmp_path: Path): + body = ( + "namespace App;\n" + "public interface IGreeter\n" + "{\n" + " string Hello() => \"hi\";\n" + "}\n" + ) + path = self._write(tmp_path, "IGreeter.cs", body) + assert is_dotnet_logic_free(path) is False + + def test_generic_constrained_method_body_enforces(self, tmp_path: Path): + """A generic method's `where` constraint sits between `)` and `{`; the body is still logic. + + The body uses no statement keyword and no `=>`, so only the `)`...`{` body check can + catch it — the constraint must not let real logic slip through as logic-free. + """ + body = ( + "namespace App;\n" + "public class Registrar\n" + "{\n" + " public void Register(IServiceCollection services) where T : class\n" + " {\n" + " services.AddSingleton();\n" + " }\n" + "}\n" + ) + path = self._write(tmp_path, "Registrar.cs", body) + assert is_dotnet_logic_free(path) is False + + def test_event_accessor_body_enforces(self, tmp_path: Path): + """Explicit event add/remove accessors carry executable logic — enforce.""" + body = ( + "namespace App;\n" + "public class Publisher\n" + "{\n" + " private EventHandler _handlers;\n" + " public event EventHandler Changed\n" + " {\n" + " add { _handlers += value; }\n" + " remove { _handlers -= value; }\n" + " }\n" + "}\n" + ) + path = self._write(tmp_path, "Publisher.cs", body) + assert is_dotnet_logic_free(path) is False + + def test_razor_is_never_logic_free(self, tmp_path: Path): + path = self._write(tmp_path, "Counter.razor", "

Count

\n") + assert is_dotnet_logic_free(path) is False + + def test_missing_file_enforces(self, tmp_path: Path): + assert is_dotnet_logic_free(str(tmp_path / "DoesNotExist.cs")) is False + + def test_no_type_declaration_enforces(self, tmp_path: Path): + """A .cs file with only usings/attributes (no type) is ambiguous — enforce.""" + path = self._write(tmp_path, "AssemblyInfo.cs", "using System;\n[assembly: System.Reflection.AssemblyVersion(\"1.0\")]\n") + assert is_dotnet_logic_free(path) is False + + +class TestFindDotnetTestDirs: + """Test-project detection must match .NET conventions without matching lookalike words.""" + + def test_matches_test_projects_but_not_words_ending_in_test(self, tmp_path: Path): + for name in ("MyApp.Tests", "MyApp.Test", "IntegrationTests", "FooTest", "tests", "latest", "contest", "greatest"): + (tmp_path / name).mkdir() + src = tmp_path / "src" + src.mkdir() + + found = {p.name for p in _find_dotnet_test_dirs(src)} + + assert {"MyApp.Tests", "MyApp.Test", "IntegrationTests", "FooTest", "tests"} <= found + assert found & {"latest", "contest", "greatest"} == set() diff --git a/pilot/rules/standards-blazor.md b/pilot/rules/standards-blazor.md new file mode 100644 index 00000000..64f4943e --- /dev/null +++ b/pilot/rules/standards-blazor.md @@ -0,0 +1,34 @@ +--- +paths: + - "**/*.razor" + - "**/*.razor.css" + - "**/*.razor.cs" +--- + +## Blazor Standards + +Framework-specific guidance for Blazor components. + +### Components + +- **Parameters:** `[Parameter]` properties with sensible defaults; `[EditorRequired]` for mandatory ones. +- **Parent notification:** use `EventCallback` — never call `StateHasChanged()` from a child to refresh a parent (`EventCallback` triggers it automatically). +- **Shared state:** prefer `[CascadingParameter]` or an injected, registered state service over deep parameter drilling. +- **Code organization:** keep markup in `.razor`, logic in a code-behind partial class (`MyComponent.razor.cs`) — avoid large `@code` blocks. + +### Styling + +- Use CSS isolation (`MyComponent.razor.css`); styles auto-scope via `b-{hash}` — no BEM/naming needed. Use `::deep` to reach child markup. + +### Rendering & Lifecycle + +- Choose render mode deliberately: `InteractiveServer` / `InteractiveWebAssembly` only when interactivity is needed — don't default to interactive when static SSR suffices. +- Lists: set `@key` so the diff algorithm tracks items; override `ShouldRender()` to skip needless re-renders on hot paths. +- Dispose: `@implements IDisposable` / `IAsyncDisposable` to release timers, event handlers, and subscriptions (a common leak source in `InteractiveServer`). + +### Checklist + +- [ ] Parameters typed with `[Parameter]`; mandatory ones `[EditorRequired]` +- [ ] Parent updates via `EventCallback`, not child-side `StateHasChanged()` +- [ ] Logic in code-behind; CSS isolation for component styles +- [ ] `@key` on lists; render mode intentional; disposables implemented diff --git a/pilot/rules/standards-dotnet.md b/pilot/rules/standards-dotnet.md new file mode 100644 index 00000000..ffa8dc4f --- /dev/null +++ b/pilot/rules/standards-dotnet.md @@ -0,0 +1,69 @@ +--- +paths: + - "**/*.cs" + - "**/*.csproj" + - "**/*.sln" +--- + +## .NET / C# Development Standards + +**Standards:** `dotnet` CLI for everything | quiet output | analyzers + nullable enforced | self-documenting code. + +### Tooling + +```bash +dotnet build # build +dotnet run --project src/MyApp # run +dotnet test -v q # quiet (preferred); AVOID -v d/diag unless debugging +dotnet test --filter "Category=Unit" # run a single category +dotnet test -- RunConfiguration.MaxCpuCount=1 # run the FULL suite single-core — avoids CI/CD parallelism flukes +dotnet format # format +dotnet format --verify-no-changes # format check (CI) +dotnet add package # add a dependency (never hand-edit version pins blindly) +``` + +### Project Configuration (enforce in `.csproj` / `Directory.Build.props`) + +- `enable` — fix nullable warnings, don't suppress with `null!` +- `enable` +- `true` — warnings fail the build +- `true` with `latest-recommended` +- Categorize tests (unit vs integration) using your test framework's category attribute + +### Reminders + +- **Async:** `async Task` over `async void`; never block on async (`.Result` / `.Wait()` / `GetAwaiter().GetResult()`). +- **HTTP:** `IHttpClientFactory` / typed clients — never `new HttpClient()` (socket exhaustion). +- **Exceptions:** catch specific types; re-throw with `throw;` (preserves the stack), never `throw ex;`. +- **Logging:** inject `ILogger`; structured templates (`Log.Information("Order {OrderId}", id)`), not interpolation. + +### ASP.NET + +- Minimal APIs for simple endpoints; controllers when you need shared filters/conventions. +- Bind config to `IOptions` instead of reading `IConfiguration` in services. +- Return `ProblemDetails` for errors; configure `app.UseExceptionHandler()` (no stack traces in prod). +- Policy-based authorization (`[Authorize(Policy = "...")]`); keep auth logic out of controllers. + +### Testing & Mocking + +Use constructor injection + interfaces so dependencies can be substituted in tests: + +| Dependency | Substitute with | +|------------|-----------------| +| HTTP | `IHttpClientFactory` (or a fake `HttpMessageHandler`) | +| File I/O | `IFileSystem` (System.IO.Abstractions) | +| Database | `DbContext` in-memory provider, or a mocked repository interface | +| Time | `TimeProvider` (.NET 8+) or `IClock` — never `DateTime.Now` directly | +| Config | `IOptions` — `Options.Create(new MyOptions { ... })` | + +- ASP.NET integration tests: `WebApplicationFactory`. +- Don't share mutable state across tests; use your framework's setup/teardown lifecycle for async init/cleanup and dispose fixtures (connections, temp files). +- Prefer `TaskCompletionSource` over polling/`Task.Delay` when waiting on async results. + +### Verification Checklist + +- [ ] `dotnet build` — clean (zero warnings; `TreatWarningsAsErrors`) +- [ ] `dotnet test` — pass +- [ ] `dotnet format --verify-no-changes` — formatted +- [ ] No analyzer / nullable warnings +- [ ] Production files ideally under 800 lines (1000+ = consider splitting) diff --git a/pilot/rules/standards-frontend.md b/pilot/rules/standards-frontend.md index e65ce2b5..3d40c085 100644 --- a/pilot/rules/standards-frontend.md +++ b/pilot/rules/standards-frontend.md @@ -5,11 +5,13 @@ paths: - "**/*.html" - "**/*.vue" - "**/*.svelte" + - "**/*.razor" - "**/*.css" - "**/*.scss" - "**/*.sass" - "**/*.less" - "**/*.module.css" + - "**/*.razor.css" --- # Frontend Standards @@ -26,7 +28,7 @@ paths: ## CSS -**Follow project methodology consistently. Identify first: Utility-first (Tailwind), CSS Modules, BEM, or CSS-in-JS. Never mix.** +**Follow project methodology consistently. Identify first: Utility-first (Tailwind), CSS Modules, BEM, CSS-in-JS, or CSS isolation. Never mix.** - Use design tokens (`var(--color-primary)`) over hardcoded values - Work with the framework — if you need `!important`, reconsider your approach diff --git a/pilot/rules/testing.md b/pilot/rules/testing.md index f815b2e6..6a0a4f61 100644 --- a/pilot/rules/testing.md +++ b/pilot/rules/testing.md @@ -16,7 +16,7 @@ The structure of tests should be **contra-variant** with the structure of code ( #### Red-Green-Refactor -1. **RED** — One minimal test for the desired behavior. Behavior, not implementation. Mocks for external deps only. Naming: Python `test___` | TS `it("should when ")`. +1. **RED** — One minimal test for the desired behavior. Behavior, not implementation. Mocks for external deps only. Naming: Python `test___` | TS `it("should when ")` | C# `MethodName_Scenario_ExpectedResult`. 2. **VERIFY RED** — Run it; confirm it fails because the feature doesn't exist (not syntax). If it passes → rewrite. 3. **GREEN** — Simplest code that passes. No extras, no refactor. Hardcoding is fine. 4. **VERIFY GREEN** — Full suite passes. Check diagnostics.