diff --git a/CHANGELOG.md b/CHANGELOG.md index 1742c408..4f9365db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Short version: Drift installs as a Claude Code plugin and works inside the agent - guard Go - guard Rust, and stop conflating packages in a workspace - guard Java and Kotlin +- guard C# ### Fixed diff --git a/README.md b/README.md index 1562d968..205fe0a6 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ plugin that fires in every project you open has no business leaving a directory in each of them. A project that wants the index alongside its source opts in by creating a `.drift/` directory. -**Seven file types in one index** — Python, TypeScript, JavaScript, Go, Rust, -Java and Kotlin (`.py .ts .tsx .js .jsx .mjs .cjs .go .rs .java .kt .kts`), +**Eight file types in one index** — Python, TypeScript, JavaScript, Go, Rust, +Java, Kotlin and C# +(`.py .ts .tsx .js .jsx .mjs .cjs .go .rs .java .kt .kts .cs`), in one index — a name defined in Python is found from TypeScript, because `validate_token`, `validateToken` and `ValidateToken` all normalise to the same thing. Other languages pass through untouched. diff --git a/docs-site/claude-code-plugin.md b/docs-site/claude-code-plugin.md index a1a1c3d5..b172eaf5 100644 --- a/docs-site/claude-code-plugin.md +++ b/docs-site/claude-code-plugin.md @@ -17,8 +17,8 @@ business leaving a directory in each of them. A project that wants the index alongside its source opts in by creating a `.drift/` directory; `DRIFT_CACHE_HOME` overrides both. -!!! note "Python, TypeScript, JavaScript, Go, Rust, Java and Kotlin" - `.py .ts .tsx .js .jsx .mjs .cjs .go .rs .java .kt .kts`, all in one index — a name defined in +!!! note "Python, TypeScript, JavaScript, Go, Rust, Java, Kotlin and C#" + `.py .ts .tsx .js .jsx .mjs .cjs .go .rs .java .kt .kts .cs`, all in one index — a name defined in Python is found from Go, because `validate_token`, `validateToken` and `ValidateToken` all normalise to the same thing. Python is parsed with `ast`; the others are matched against top-level declarations, diff --git a/src/drift/guard/build.py b/src/drift/guard/build.py index c93d700e..0327dd00 100644 --- a/src/drift/guard/build.py +++ b/src/drift/guard/build.py @@ -114,7 +114,13 @@ def import_to_dir( """ if specifier.startswith("."): return relative_to_dir(specifier, src_dir, known_dirs) - if suffix in extract.GO_SUFFIXES | extract.RUST_SUFFIXES | extract.JVM_SUFFIXES: + if ( + suffix + in extract.GO_SUFFIXES + | extract.RUST_SUFFIXES + | extract.JVM_SUFFIXES + | extract.CSHARP_SUFFIXES + ): return suffix_to_dir(specifier, known_dirs) if "/" in specifier: return None @@ -150,6 +156,10 @@ def _sha256(path: pathlib.Path) -> str: "build.gradle.kts", ) +#: Markers whose name varies. A .NET project is `Whatever.csproj`, so the +#: directory has to be searched rather than probed. +PACKAGE_MARKER_GLOBS = ("*.csproj", "*.fsproj") + def package_root_of(repo_root: pathlib.Path, rel_path: str, cache: dict[str, str]) -> str: """The package a file belongs to, as a repo-relative directory. @@ -171,7 +181,9 @@ def package_root_of(repo_root: pathlib.Path, rel_path: str, cache: dict[str, str for depth in range(len(parts), -1, -1): candidate = "/".join(parts[:depth]) if depth else "." base = repo_root if candidate == "." else repo_root / candidate - if any((base / marker).exists() for marker in PACKAGE_MARKERS): + if any((base / marker).exists() for marker in PACKAGE_MARKERS) or any( + next(base.glob(pattern), None) is not None for pattern in PACKAGE_MARKER_GLOBS + ): found = candidate break diff --git a/src/drift/guard/extract.py b/src/drift/guard/extract.py index 64d4c875..08a232f6 100644 --- a/src/drift/guard/extract.py +++ b/src/drift/guard/extract.py @@ -27,7 +27,10 @@ GO_SUFFIXES = frozenset({".go"}) RUST_SUFFIXES = frozenset({".rs"}) JVM_SUFFIXES = frozenset({".java", ".kt", ".kts"}) -GUARDED_SUFFIXES = PYTHON_SUFFIXES | TS_JS_SUFFIXES | GO_SUFFIXES | RUST_SUFFIXES | JVM_SUFFIXES +CSHARP_SUFFIXES = frozenset({".cs"}) +GUARDED_SUFFIXES = ( + PYTHON_SUFFIXES | TS_JS_SUFFIXES | GO_SUFFIXES | RUST_SUFFIXES | JVM_SUFFIXES | CSHARP_SUFFIXES +) #: Normalized names too common to ever be a meaningful duplicate. STOPWORDS = frozenset( @@ -196,6 +199,28 @@ _JVM_IMPORT = re.compile(r"^import\s+(?:static\s+)?([\w.]+(?:\*)?)", re.MULTILINE) +#: C# declarations. Members are indented, so the column-zero anchor excludes +#: them — except that C# nests types inside a namespace block, which indents +#: everything by one level. Both indents are therefore allowed for types, and +#: the file-scoped `namespace X;` form needs no allowance at all. +_CSHARP_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ( + "type", + re.compile( + r"^[ \t]{0,4}(?:\[[^\]]+\]\s*)*" + r"(?:public\s+|internal\s+|private\s+|protected\s+|sealed\s+|abstract\s+" + r"|static\s+|partial\s+|readonly\s+|record\s+)*" + r"(?:class|interface|struct|enum|record)\s+([A-Za-z_][\w]*)", + re.MULTILINE, + ), + ), +) + +#: `using A.B.C;` names a namespace, which C# projects mirror as directories. +#: `using X = A.B;` is an alias rather than a dependency on a place. +_CSHARP_USING = re.compile(r"^using\s+(?:static\s+)?([A-Za-z_][\w.]*)\s*;", re.MULTILINE) + + class Symbol(NamedTuple): name: str norm_name: str @@ -239,9 +264,42 @@ def extract(source: str, suffix: str = ".py") -> tuple[list[Symbol], list[str]]: return extract_rust(source) if suffix in JVM_SUFFIXES: return extract_jvm(source) + if suffix in CSHARP_SUFFIXES: + return extract_csharp(source) return ([], []) +def extract_csharp(source: str) -> tuple[list[Symbol], list[str]]: + """C#, matched rather than parsed. + + `using A.B.C;` becomes `A/B/C`, because a C# project mirrors its namespace + in the directory tree — the same assumption Java relies on, and the same + trailing-segment resolver finds it. + """ + symbols: list[Symbol] = [] + seen: set[str] = set() + + for kind, pattern in _CSHARP_PATTERNS: + for match in pattern.finditer(source): + name = match.group(1) + if name in seen: + continue + seen.add(name) + symbols.append( + Symbol( + name=name, + norm_name=normalize(name), + kind=kind, + sig_hash=signature_hash(kind, []), + line=source.count("\n", 0, match.start()) + 1, + ) + ) + + imports = ["/".join(m.group(1).split(".")) for m in _CSHARP_USING.finditer(source)] + symbols.sort(key=lambda s: s.line) + return (symbols, imports) + + def extract_jvm(source: str) -> tuple[list[Symbol], list[str]]: """Java and Kotlin, matched rather than parsed. diff --git a/src/drift/guard/lookup.py b/src/drift/guard/lookup.py index a1dc65a5..c3f2dea2 100644 --- a/src/drift/guard/lookup.py +++ b/src/drift/guard/lookup.py @@ -85,13 +85,19 @@ def repeats_by_design(rel_path: str) -> bool: return True if parts and parts[0] in _REPEATING_ROOT_DIRS: return True + # `Newtonsoft.Json.Tests` — .NET names a test project after the project it + # covers, so the segment is never exactly "tests". + if any(part.lower().endswith((".tests", ".test", ".specs", ".spec")) for part in parts[:-1]): + return True stem = parts[-1].rsplit(".", 1)[0] - return ( - stem in ("test", "spec") - or stem.startswith(("test_", "test-")) - or stem.endswith(("_test", "-test", ".test", ".spec", "_spec")) - ) + if stem in ("test", "spec") or stem.startswith(("test_", "test-")): + return True + if stem.endswith(("_test", "-test", ".test", ".spec", "_spec")): + return True + # Generated code restates whatever it was generated from, and no one edits + # it: `Model.designer.cs`, `Api.g.cs`, `Schema.generated.ts`. + return stem.lower().endswith((".designer", ".g", ".generated", "_pb2", "_pb")) def find_duplicates( diff --git a/tests/guard/test_extract_csharp.py b/tests/guard/test_extract_csharp.py new file mode 100644 index 00000000..9faab433 --- /dev/null +++ b/tests/guard/test_extract_csharp.py @@ -0,0 +1,143 @@ +"""C#: what the matcher finds, and what it refuses to invent.""" + +from __future__ import annotations + +from drift.guard import build, extract, lookup, schema + +BLOCK_SCOPED = """\ +using System.Text; +using MyApp.Db; +using static MyApp.Util.Helpers; +using Alias = MyApp.Legacy; + +namespace MyApp.Auth +{ + [Service] + public sealed class TokenStore : IValidator + { + public bool Validate(string token) + { + return true; + } + + private void InternalDetail() { } + } + + public interface IValidator + { + bool Validate(string token); + } + + public enum Outcome + { + Accepted + } +} +""" + +FILE_SCOPED = """\ +using MyApp.Db; + +namespace MyApp.Auth; + +public record TokenPair(string Left, string Right); + +public struct Marker { } +""" + + +def _names(source: str) -> list[str]: + symbols, _ = extract.extract(source, ".cs") + return [s.name for s in symbols] + + +def test_types_nested_in_a_namespace_block_are_found(): + """C# indents everything by one level, which no other language here does.""" + names = _names(BLOCK_SCOPED) + + assert "TokenStore" in names + assert "IValidator" in names + assert "Outcome" in names + + +def test_the_file_scoped_namespace_form_is_found(): + names = _names(FILE_SCOPED) + + assert "TokenPair" in names + assert "Marker" in names + + +def test_members_are_not_indexed(): + names = _names(BLOCK_SCOPED) + + assert "Validate" not in names + assert "InternalDetail" not in names + + +def test_usings_become_directory_paths(): + _, imports = extract.extract(BLOCK_SCOPED, ".cs") + + assert "MyApp/Db" in imports + assert "System/Text" in imports + assert "MyApp/Util/Helpers" in imports, "a static using ends at the class" + + +def test_an_alias_is_not_a_dependency_on_a_place(): + """`using Alias = MyApp.Legacy;` renames rather than reaches.""" + _, imports = extract.extract("using Alias = MyApp.Legacy;\n", ".cs") + + assert imports == [] + + +def test_a_word_inside_a_string_is_not_a_declaration(): + source = 'namespace A;\n\npublic class Real { string s = "public class NotReal {}"; }\n' + + assert _names(source) == ["Real"] + + +def _write_csharp_repo(root): + (root / "src" / "MyApp" / "Auth").mkdir(parents=True) + (root / "src" / "MyApp" / "Db").mkdir(parents=True) + (root / "src" / "MyApp" / "Api").mkdir(parents=True) + (root / "src" / "MyApp" / "MyApp.csproj").write_text("\n", encoding="utf-8") + (root / "src" / "MyApp" / "Auth" / "TokenStore.cs").write_text( + "namespace MyApp.Auth;\n\npublic class TokenStore { }\n", encoding="utf-8" + ) + (root / "src" / "MyApp" / "Db" / "Client.cs").write_text( + "namespace MyApp.Db;\n\npublic class Client { }\n", encoding="utf-8" + ) + (root / "src" / "MyApp" / "Api" / "Routes.cs").write_text( + "using MyApp.Auth;\n\nnamespace MyApp.Api;\n\npublic class Routes { }\n", + encoding="utf-8", + ) + + +def test_a_csharp_repository_reports_a_duplicate(tmp_path): + _write_csharp_repo(tmp_path) + build.build_full(tmp_path) + conn = schema.connect(tmp_path) + + symbols, _ = extract.extract("namespace MyApp.Api;\n\npublic class TokenStore { }\n", ".cs") + hits = lookup.find_duplicates(conn, "src/MyApp/Api/TokenStore.cs", symbols) + + assert hits + assert "Auth/TokenStore.cs" in hits[0].message + + +def test_a_csharp_repository_reports_a_first_ever_boundary(tmp_path): + _write_csharp_repo(tmp_path) + build.build_full(tmp_path) + conn = schema.connect(tmp_path) + + hits = lookup.find_novel_edges(conn, "src/MyApp/Api/Routes.cs", ["MyApp/Db"]) + + assert hits, "Api has never used Db" + assert "Api" in hits[0].message and "Db" in hits[0].message + + +def test_a_csproj_marks_a_package(tmp_path): + """The marker name varies per project, so the directory has to be searched.""" + (tmp_path / "src" / "Thing").mkdir(parents=True) + (tmp_path / "src" / "Thing" / "Thing.csproj").write_text("\n", encoding="utf-8") + + assert build.package_root_of(tmp_path, "src/Thing/Model.cs", {}) == "src/Thing" diff --git a/tests/guard/test_lookup.py b/tests/guard/test_lookup.py index 53507eeb..bc4d9be5 100644 --- a/tests/guard/test_lookup.py +++ b/tests/guard/test_lookup.py @@ -259,3 +259,17 @@ def test_a_top_level_example_directory_still_repeats_by_design(): assert lookup.repeats_by_design("example/main.go") assert lookup.repeats_by_design("examples/celery/app.py") assert lookup.repeats_by_design("pkgs/core/examples/cjs/test.cjs") + + +def test_dotnet_style_test_projects_repeat_by_design(): + """.NET names a test project after the project it covers.""" + assert lookup.repeats_by_design("Src/Newtonsoft.Json.Tests/Schema/JsonSchemaTests.cs") + assert lookup.repeats_by_design("src/MyApp.Test/Thing.cs") + assert not lookup.repeats_by_design("src/MyApp.Core/Thing.cs") + + +def test_generated_files_repeat_whatever_generated_them(): + assert lookup.repeats_by_design("src/Model.designer.cs") + assert lookup.repeats_by_design("src/Api.g.cs") + assert lookup.repeats_by_design("src/schema_pb2.py") + assert not lookup.repeats_by_design("src/designer.py")