Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs-site/claude-code-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions src/drift/guard/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
60 changes: 59 additions & 1 deletion src/drift/guard/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
16 changes: 11 additions & 5 deletions src/drift/guard/lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
143 changes: 143 additions & 0 deletions tests/guard/test_extract_csharp.py
Original file line number Diff line number Diff line change
@@ -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("<Project/>\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("<Project/>\n", encoding="utf-8")

assert build.package_root_of(tmp_path, "src/Thing/Model.cs", {}) == "src/Thing"
14 changes: 14 additions & 0 deletions tests/guard/test_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading