diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 289ed42..31f93b0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ What actually happened. Include the JSON output if relevant. - **OS:** (e.g., macOS 14, Ubuntu 24.04, Windows 11) - **Contextception version:** (`contextception --version`) - **Go version:** (`go version`, if built from source) -- **Language of analyzed files:** (Python, TypeScript, Go, Java, Rust) +- **Language of analyzed files:** (Python, TypeScript, Go, Java, Rust, C#) ## Additional Context diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f1fbc..08c3ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,7 +93,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **5-language support:** Python, TypeScript/JavaScript, Go, Java, Rust +- **6-language support:** Python, TypeScript/JavaScript, Go, Java, Rust, C# - **CLI with 10 commands:** analyze, analyze-change, search, archetypes, history, index, reindex, extensions, status, mcp - **MCP server** with 8 tools for integration with Claude Code, Cursor, Windsurf, and other AI tools - **Schema 3.2 output** with confidence scoring, role classification, code signatures, and direction field diff --git a/CLAUDE.md b/CLAUDE.md index 5d06e14..72a2d1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Contextception** is a code context intelligence engine written in **Go**. It answers: *"What code must be understood before making a safe change?"* It is not a code generator, AI assistant, or IDE — it determines what matters, not what to do. -Supports 5 languages: Python, TypeScript/JavaScript, Go, Java, and Rust. Available as a CLI (16 commands) and MCP server (9 tools). +Supports 6 languages: Python, TypeScript/JavaScript, Go, Java, Rust, and C#. Available as a CLI (16 commands) and MCP server (9 tools). ## Tech Stack @@ -65,7 +65,7 @@ internal/ cli/ Command handlers (cobra subcommands) config/ Configuration parsing (per-repo + global config) db/ SQLite layer (migrations, store, search) - extractor/ Language extractors (python, typescript, golang, java, rust) + extractor/ Language extractors (python, typescript, golang, java, rust, csharp) git/ Git history signal extraction grader/ Internal quality evaluation framework history/ Historical analysis, usage tracking, and feedback storage diff --git a/README.md b/README.md index 510bd63..71e6460 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Contextception builds a dependency graph of your repository and returns ranked, 97% Precision (independent ground truth)  ·  Tested across 16 repos  ·  Sub-second Analysis  ·  - 5 Languages  ·  + 6 Languages  ·  Free & Open Source

@@ -71,7 +71,7 @@ $ contextception index Indexed 2,638 files, 10,087 edges in 0.9s ``` -Scans your codebase, extracts imports across 5 languages, resolves dependencies, computes git history signals. **Incremental:** only changed files reprocessed. +Scans your codebase, extracts imports across 6 languages, resolves dependencies, computes git history signals. **Incremental:** only changed files reprocessed. ### 2. Analyze any file @@ -347,6 +347,7 @@ These work by combining contextception's deterministic risk analysis with the LL | **Go** | Regex | go.mod + go.work, same-package resolution | `.go` | | **Java** | Regex | Package-to-directory, mirror-directory test discovery | `.java` | | **Rust** | Regex | Cargo workspaces, mod.rs, crate/super/self paths, inline test detection | `.rs` | +| **C#** | Regex | .csproj project detection, namespace-to-file resolution, filename search fallback | `.cs` | --- @@ -457,7 +458,7 @@ generated: ## Tested Across 16 Repositories -Indexed and analyzed real-world codebases spanning all 5 supported languages: +Indexed and analyzed real-world codebases spanning all 6 supported languages: | Repository | Language | Files | |-----------|----------|-------| @@ -475,10 +476,12 @@ Indexed and analyzed real-world codebases spanning all 5 supported languages: | Kafka | Java | 3,200+ | | Tokio | Rust | 1,021 | | Bevy | Rust | 2,400+ | +| EF Core | C# | 5,708 | +| Jellyfin | C# | 1,971 | | Medusa | TypeScript | 1,800+ | | supermemory | TypeScript | 200+ | -**Tested across 419 files spanning all 5 supported languages.** +**Tested across 419+ files spanning all 6 supported languages.** --- @@ -488,7 +491,7 @@ Contextception is a standalone static analysis tool, not an AI coding assistant. | Capability | Contextception | Aider repo-map | Repomix | |------------|:-:|:-:|:-:| -| Static dependency graph | Full (5 languages) | Partial (tree-sitter) | No | +| Static dependency graph | Full (6 languages) | Partial (tree-sitter) | No | | Per-file relevance ranking | Yes | PageRank-based | No (full dump) | | Explainability (direction, symbols, role) | Yes | No | No | | Blast radius / risk scoring | Yes | No | No | diff --git a/benchmarks/README.md b/benchmarks/README.md index 98b9206..eca5e9f 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,12 +1,12 @@ # How Contextception Compares -A context quality comparison between Contextception, [Aider's repo-map](https://aider.chat/docs/repomap.html), and [Repomix](https://github.com/yamadashy/repomix), tested across 6 repos, 51 files, and 4 languages. +A context quality comparison between Contextception, [Aider's repo-map](https://aider.chat/docs/repomap.html), and [Repomix](https://github.com/yamadashy/repomix), tested across 7 repos, 68 files, and 5 languages. ## TL;DR - On httpx (independent fixture ground truth): **97% recall vs. Aider@4K's 83%** (Aider@8K: 90%), at 5x fewer tokens - Aider's recall drops from **97% → 0%** as repos grow from 60 → 7,978 files -- Contextception averages **1,091 tokens** per analysis; Aider@4K averages **3,600 tokens** with lower recall +- Contextception averages **1,174 tokens** per analysis; Aider@4K averages **3,600 tokens** with lower recall ## Limitations @@ -14,8 +14,8 @@ Read these first — they're the reason you should (or shouldn't) trust these nu 1. **Aider's repo-map serves a different purpose.** It's designed as internal LLM context for Aider's own editing workflow, not as a standalone dependency analysis tool. We're evaluating it outside its intended use case. 2. **Independent ground truth exists only for httpx.** The httpx comparisons use 5 expert-verified [fixture files](data/fixtures/) with hand-curated `must_read` lists. For other repos, ground truth is Contextception's own output, validated at grade A (3.76–3.97) across 23 evaluation rounds. -3. **Aider gets 0% on Go, Java, and Rust** because its tree-sitter parser doesn't resolve module imports for these languages. This is a real limitation of the tool, not a testing artifact — but it means the comparison is lopsided for 3 of 6 repos. -4. **Sample size: 51 files across 6 repos**, selected by archetype diversity (one file per structural role per repo). Not exhaustive. +3. **Aider gets 0–3% on Go, Java, Rust, and C#** because its tree-sitter parser doesn't resolve module imports for these languages. This is a real limitation of the tool, not a testing artifact — but it means the comparison is lopsided for 4 of 7 repos. +4. **Sample size: 68 files across 7 repos**, selected by archetype diversity (one file per structural role per repo). Not exhaustive. ## What We Measured @@ -44,12 +44,13 @@ The key finding: Aider's recall degrades as repository size increases, while Con | Tokio | Rust | 763 | 0% | 1% | 812 | | Terraform | Go | 1,885 | 3% | 8% | 941 | | Zulip | Python/TS | 2,638 | 21% | 27% | 837 | +| EF Core | C# | 5,708 | 2% | 3% | 1,420 | | Spring Boot | Java | 7,978 | 0% | 0% | 2,109 | **Why this happens:** - **Python (httpx → Zulip):** Aider uses PageRank on a global definition graph. In a small repo, globally important files overlap with local dependencies. In a large repo, globally popular files (models.py, utils.py) crowd out the specific imports that matter for a given file. -- **Go, Java, Rust:** Aider's tree-sitter parser doesn't resolve module specifiers to file paths. `import "internal/tfdiags"` doesn't create an edge to any file — it just notes that symbols are referenced. Contextception resolves these via go.mod, Java package conventions, and Cargo workspaces. +- **Go, Java, Rust, C#:** Aider's tree-sitter parser doesn't resolve module specifiers to file paths. `import "internal/tfdiags"` doesn't create an edge to any file — it just notes that symbols are referenced. Contextception resolves these via go.mod, Java package conventions, Cargo workspaces, and .csproj project detection. - **TypeScript:** Aider doesn't resolve tsconfig paths, workspace packages, or barrel exports. `@excalidraw/utils` doesn't map to `packages/utils/src/index.ts`. ### httpx Deep Dive (Fixture Ground Truth) @@ -67,12 +68,12 @@ Contextception matches or exceeds Aider's best recall while maintaining near-per ### Token Efficiency -| Tool | httpx | Zulip | Excalidraw | Terraform | Tokio | Spring Boot | -|------|------:|------:|-----------:|----------:|------:|------------:| -| **Contextception** | 748 | 837 | 990 | 941 | 812 | 2,109 | -| **Aider@4K** | ~3,300 | ~4,200 | ~3,300 | ~3,700 | ~3,200 | ~4,200 | -| **Aider@8K** | ~6,700 | ~7,200 | ~6,900 | ~6,900 | ~7,000 | ~8,400 | -| **Repomix** | 198K | 17.6M | 2.5M | 5.6M | 1.4M | 9.8M | +| Tool | httpx | Zulip | Excalidraw | Terraform | Tokio | EF Core | Spring Boot | +|------|------:|------:|-----------:|----------:|------:|--------:|------------:| +| **Contextception** | 748 | 837 | 990 | 941 | 812 | 1,420 | 2,109 | +| **Aider@4K** | ~3,300 | ~4,200 | ~3,300 | ~3,700 | ~3,200 | ~3,400 | ~4,200 | +| **Aider@8K** | ~6,700 | ~7,200 | ~6,900 | ~6,900 | ~7,000 | ~7,100 | ~8,400 | +| **Repomix** | 198K | 17.6M | 2.5M | 5.6M | 1.4M | 23.2M | 9.8M | *Values are average tokens per file analysis.* @@ -151,6 +152,33 @@ Rust's module system (crate paths, `mod` declarations, `use` re-exports) is invi +
+EF Core (C#, 5,708 files) — Aider avg recall: 3% @8K + +| File | Archetype | CC must_read | Aider@4K Recall | Aider@8K Recall | +|------|-----------|-------------:|----------------:|----------------:| +| `SqlServerServiceCollectionExtensions.cs` | Service | 10 | 0% | 0% | +| `CountryRegion.cs` | Model | 10 | 0% | 10% | +| `IMemberTranslatorPlugin.cs` | Plugin | 10 | 0% | 0% | +| `IJsonValueReaderWriterSource.cs` | Utility | 10 | 0% | 0% | +| `ViewColumnBuilder.cs` | Endpoint | 10 | 0% | 0% | +| `SessionTokenStorageFactory.cs` | Auth | 10 | 10% | 10% | +| `RelationalConverterMappingHints.cs` | Leaf | 0 | 0% | 0% | +| `ConfigurationSourceExtensions.cs` | Config | 10 | 0% | 0% | +| `DbSetOperationTests.cs` | Test | 1 | 0% | 0% | +| `MigrationsOperations.cs` | Migration | 10 | 10% | 10% | +| `AdHocMapper.cs` | Serialization | 10 | 10% | 10% | +| `DefaultValueBinding.cs` | Error | 10 | 10% | 10% | +| `CosmosClientWrapper.cs` | CLI | 10 | 0% | 0% | +| `CommandErrorEventData.cs` | Event | 10 | 0% | 0% | +| `CSharpDbContextGenerator.Interfaces.cs` | Interface | 10 | 0% | 0% | +| `ITableBasedExpression.cs` | Orchestrator | 10 | 0% | 0% | +| `ComplexTypesTrackingSqlServerTest.cs` | Hotspot | 8 | 0% | 0% | + +C# uses namespace-level `using` directives (`using Microsoft.EntityFrameworkCore;`), which Aider's tree-sitter parser cannot resolve to specific files. At 5,708 files, Aider outputs 130–160 files per query but finds only 2–3% of actual dependencies. Contextception resolves namespaces via `.csproj` project detection, namespace-to-directory mapping, and same-namespace sibling discovery. + +
+
Spring Boot (Java, 7,978 files) — Aider avg recall: 0% @8K @@ -186,7 +214,7 @@ These fixtures were written by inspecting httpx source code directly — not der ### Tier 2: Validated CC Output (Other Repos) -For the remaining 5 repos, ground truth is Contextception's own `must_read` output, independently validated at grade A across 23 evaluation rounds and 16 repos. Validation grades: +For the remaining 6 repos, ground truth is Contextception's own `must_read` output, independently validated at grade A across evaluation rounds. Validation grades: | Repo | Grade | Evaluation Rounds | |------|-------|-------------------| @@ -195,6 +223,7 @@ For the remaining 5 repos, ground truth is Contextception's own `must_read` outp | Terraform | A (3.78) | Rounds 13–18 | | Tokio | A (3.79) | Rounds 11, 20–23 | | Spring Boot | A (3.76) | Rounds 13, 19 | +| EF Core | A (3.85) | C# Rounds 5–6 | This creates a circular validation concern: Contextception gets 100% recall against its own output by definition. The comparison is still meaningful because Aider's recall is measured against the same ground truth — but "CC recall = 100%" should be read as "CC's output was validated as grade A" rather than "CC found everything." @@ -232,6 +261,6 @@ See [methodology.md](methodology.md) for: ## Raw Data -- [`data/results.json`](data/results.json) — Complete results for all 6 repos, 51 files +- [`data/results.json`](data/results.json) — Complete results for all 7 repos, 68 files - [`data/fixtures/`](data/fixtures/) — httpx fixture ground truth files - [`scripts/compare/`](../scripts/compare/) — Comparison scripts diff --git a/benchmarks/data/results.json b/benchmarks/data/results.json index 251b3f9..0abc833 100644 --- a/benchmarks/data/results.json +++ b/benchmarks/data/results.json @@ -1869,16 +1869,654 @@ "avg_aider_4k_recall": 0.211, "avg_aider_8k_precision": 0.021 } + }, + "efcore": { + "archetype_count": 17, + "files": [ + { + "file": "src/EFCore.SqlServer/Extensions/SqlServerServiceCollectionExtensions.cs", + "archetype": "Service/Controller", + "indegree": 0, + "outdegree": 18, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 2, + "confidence": 1, + "tokens": 1317, + "total_files": 27, + "must_read_files": [ + "src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs", + "src/EFCore/Infrastructure/Internal/ILazyLoaderFactory.cs", + "src/EFCore/Metadata/Internal/IRuntimeServiceProperty.cs", + "src/EFCore/Query/Internal/IQueryCompiler.cs", + "src/EFCore/ValueGeneration/Internal/TemporaryLongValueGenerator.cs", + "src/EFCore/Diagnostics/Internal/ScopedLoggerFactory.cs", + "src/EFCore/Update/Internal/UpdateAdapterFactory.cs", + "src/EFCore.Design/Migrations/Internal/ISnapshotModelProcessor.cs", + "src/EFCore.SqlServer/Extensions/FullTextSearchResult.cs", + "src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs" + ] + }, + "aider_4096": { + "file_count": 70, + "tokens": 3372, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 155, + "tokens": 8922, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "benchmark/EFCore.Benchmarks/Models/AdventureWorks/CountryRegion.cs", + "archetype": "Model/Schema", + "indegree": 67, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 0, + "confidence": 1, + "tokens": 2224, + "total_files": 25, + "must_read_files": [ + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/Address.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/AddressType.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/AdventureWorksContextBase.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/BillOfMaterials.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/BusinessEntity.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/BusinessEntityAddress.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/BusinessEntityContact.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/ContactType.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/CountryRegionCurrency.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/CreditCard.cs" + ] + }, + "aider_4096": { + "file_count": 68, + "tokens": 3301, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 158, + "tokens": 8764, + "recall": 0.1, + "precision": 0.006 + } + }, + { + "file": "src/EFCore.Relational/Query/IMemberTranslatorPlugin.cs", + "archetype": "Middleware/Plugin", + "indegree": 77, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 3, + "confidence": 1, + "tokens": 2151, + "total_files": 28, + "must_read_files": [ + "src/EFCore.Relational/Query/CollectionResultExpression.cs", + "src/EFCore.Relational/Query/IAggregateMethodCallTranslatorPlugin.cs", + "src/EFCore.Relational/Query/IMethodCallTranslatorPlugin.cs", + "src/EFCore.Relational/Query/ExpressionExtensions.cs", + "src/EFCore.Relational/Query/EnumerableExpression.cs", + "src/EFCore.Relational/Query/IAggregateMethodCallTranslator.cs", + "src/EFCore.Relational/Query/IAggregateMethodCallTranslatorProvider.cs", + "src/EFCore.Relational/Query/IMemberTranslator.cs", + "src/EFCore.Relational/Query/IMemberTranslatorProvider.cs", + "src/EFCore.Relational/Query/IMethodCallTranslator.cs" + ] + }, + "aider_4096": { + "file_count": 83, + "tokens": 4079, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 135, + "tokens": 7801, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore/Storage/Json/IJsonValueReaderWriterSource.cs", + "archetype": "High Fan-in Utility", + "indegree": 185, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 0, + "confidence": 1, + "tokens": 1585, + "total_files": 25, + "must_read_files": [ + "src/EFCore/Storage/Json/JsonBoolReaderWriter.cs", + "src/EFCore/Storage/Json/JsonByteArrayReaderWriter.cs", + "src/EFCore/Storage/Json/JsonByteReaderWriter.cs", + "src/EFCore/Storage/Json/JsonCharReaderWriter.cs", + "src/EFCore/Storage/Json/JsonDateOnlyReaderWriter.cs", + "src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs", + "src/EFCore/Storage/Json/JsonCastValueReaderWriter.cs", + "src/EFCore/Storage/Json/JsonCollectionOfNullableStructsReaderWriter.cs", + "src/EFCore/Storage/Json/JsonCollectionOfReferencesReaderWriter.cs", + "src/EFCore/Storage/Json/JsonCollectionOfStructsReaderWriter.cs" + ] + }, + "aider_4096": { + "file_count": 67, + "tokens": 3267, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 135, + "tokens": 7489, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Relational/Metadata/Builders/ViewColumnBuilder.cs", + "archetype": "Page/Route/Endpoint", + "indegree": 0, + "outdegree": 11, + "cc": { + "must_read_count": 10, + "likely_modify_count": 13, + "test_count": 0, + "confidence": 1, + "tokens": 1160, + "total_files": 23, + "must_read_files": [ + "src/EFCore/Metadata/Internal/IRuntimeTypeBase.cs", + "src/EFCore.Relational/Metadata/Builders/ColumnBuilder`.cs", + "src/EFCore.Relational/Metadata/Builders/IConventionCheckConstraintBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/IConventionDbFunctionParameterBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/CheckConstraintBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/ColumnBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/DbFunctionBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/DbFunctionBuilderBase.cs", + "src/EFCore.Relational/Metadata/Builders/DbFunctionParameterBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/IConventionDbFunctionBuilder.cs" + ] + }, + "aider_4096": { + "file_count": 81, + "tokens": 4007, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 147, + "tokens": 8562, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Cosmos/Storage/Internal/SessionTokenStorageFactory.cs", + "archetype": "Auth/Security", + "indegree": 0, + "outdegree": 13, + "cc": { + "must_read_count": 10, + "likely_modify_count": 11, + "test_count": 2, + "confidence": 1, + "tokens": 1080, + "total_files": 23, + "must_read_files": [ + "src/EFCore/Infrastructure/Internal/ILazyLoaderFactory.cs", + "src/EFCore/Metadata/Internal/IRuntimeNavigation.cs", + "src/EFCore/Infrastructure/IDbContextOptions.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosExecutionStrategy.cs", + "src/EFCore.Cosmos/Storage/Internal/ByteArrayConverter.cs", + "src/EFCore.Cosmos/Storage/Internal/ContainerProperties.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeOnlyReaderWriter.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeSpanReaderWriter.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs" + ] + }, + "aider_4096": { + "file_count": 68, + "tokens": 3272, + "recall": 0.1, + "precision": 0.015 + }, + "aider_8192": { + "file_count": 132, + "tokens": 7502, + "recall": 0.1, + "precision": 0.008 + } + }, + { + "file": "src/EFCore.Relational/Storage/ValueConversion/RelationalConverterMappingHints.cs", + "archetype": "Leaf Component", + "indegree": 0, + "outdegree": 0, + "cc": { + "must_read_count": 0, + "likely_modify_count": 0, + "test_count": 0, + "confidence": 1, + "tokens": 138, + "total_files": 0, + "must_read_files": [] + }, + "aider_4096": { + "file_count": 82, + "tokens": 4076, + "recall": 0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 126, + "tokens": 7339, + "recall": 0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore/Metadata/ConfigurationSourceExtensions.cs", + "archetype": "Config/Constants", + "indegree": 123, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 1, + "confidence": 1, + "tokens": 1705, + "total_files": 26, + "must_read_files": [ + "src/EFCore/Metadata/AdHocMapperDependencies.cs", + "src/EFCore/Metadata/ConfigurationSource.cs", + "src/EFCore/Metadata/ConstructorBinding.cs", + "src/EFCore/Metadata/ContextParameterBinding.cs", + "src/EFCore/Metadata/DefaultValueBinding.cs", + "src/EFCore/Metadata/EntityTypeFullNameComparer.cs", + "src/EFCore/Metadata/AdHocMapper.cs", + "src/EFCore/Metadata/DependencyInjectionParameterBinding.cs", + "src/EFCore/Metadata/IConventionProperty.cs", + "src/EFCore/Metadata/DependencyInjectionMethodParameterBinding.cs" + ] + }, + "aider_4096": { + "file_count": 67, + "tokens": 3295, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 133, + "tokens": 7489, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "benchmark/EFCore.Benchmarks/ChangeTracker/DbSetOperationTests.cs", + "archetype": "Test File", + "indegree": 0, + "outdegree": 1, + "cc": { + "must_read_count": 1, + "likely_modify_count": 0, + "test_count": 0, + "confidence": 1, + "tokens": 398, + "total_files": 1, + "must_read_files": [ + "benchmark/EFCore.Benchmarks/Models/Orders/OrderLine.cs" + ] + }, + "aider_4096": { + "file_count": 68, + "tokens": 3276, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 133, + "tokens": 7620, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Design/Design/Internal/MigrationsOperations.cs", + "archetype": "Database/Migration", + "indegree": 0, + "outdegree": 13, + "cc": { + "must_read_count": 10, + "likely_modify_count": 12, + "test_count": 5, + "confidence": 1, + "tokens": 1140, + "total_files": 27, + "must_read_files": [ + "src/EFCore/Design/Internal/ICSharpRuntimeAnnotationCodeGenerator.cs", + "src/EFCore.Design/Migrations/Design/IMigrationsCodeGenerator.cs", + "src/EFCore/Internal/IDbSetInitializer.cs", + "src/EFCore.Design/Design/Internal/ContextInfo.cs", + "src/EFCore.Design/Design/Internal/DatabaseOperations.cs", + "src/EFCore.Design/Design/Internal/AppServiceProviderFactory.cs", + "src/EFCore.Design/Design/Internal/DesignTimeConnectionStringResolver.cs", + "src/EFCore.Design/Design/Internal/DesignTimeServicesBuilder.cs", + "src/EFCore.Design/Design/Internal/CSharpHelper.cs", + "src/EFCore.Design/Design/Internal/DbContextOperations.cs" + ] + }, + "aider_4096": { + "file_count": 81, + "tokens": 3997, + "recall": 0.1, + "precision": 0.012 + }, + "aider_8192": { + "file_count": 112, + "tokens": 6087, + "recall": 0.1, + "precision": 0.009 + } + }, + { + "file": "src/EFCore/Metadata/AdHocMapper.cs", + "archetype": "Serialization/Validation", + "indegree": 123, + "outdegree": 11, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 0, + "confidence": 1, + "tokens": 1490, + "total_files": 25, + "must_read_files": [ + "src/EFCore/Metadata/Internal/IRuntimeKey.cs", + "src/EFCore/Metadata/AdHocMapperDependencies.cs", + "src/EFCore/Metadata/ConfigurationSource.cs", + "src/EFCore/Metadata/ConfigurationSourceExtensions.cs", + "src/EFCore/Metadata/ConstructorBinding.cs", + "src/EFCore/Metadata/ContextParameterBinding.cs", + "src/EFCore/Metadata/DefaultValueBinding.cs", + "src/EFCore/Metadata/EntityTypeFullNameComparer.cs", + "src/EFCore/Metadata/DependencyInjectionParameterBinding.cs", + "src/EFCore/Metadata/DependencyInjectionMethodParameterBinding.cs" + ] + }, + "aider_4096": { + "file_count": 66, + "tokens": 3221, + "recall": 0.1, + "precision": 0.015 + }, + "aider_8192": { + "file_count": 149, + "tokens": 8628, + "recall": 0.1, + "precision": 0.007 + } + }, + { + "file": "src/EFCore/Metadata/DefaultValueBinding.cs", + "archetype": "Error Handling", + "indegree": 123, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 0, + "confidence": 1, + "tokens": 1759, + "total_files": 25, + "must_read_files": [ + "src/EFCore/Metadata/AdHocMapperDependencies.cs", + "src/EFCore/Metadata/ConfigurationSource.cs", + "src/EFCore/Metadata/ConfigurationSourceExtensions.cs", + "src/EFCore/Metadata/ConstructorBinding.cs", + "src/EFCore/Metadata/ContextParameterBinding.cs", + "src/EFCore/Metadata/EntityTypeFullNameComparer.cs", + "src/EFCore/Metadata/AdHocMapper.cs", + "src/EFCore/Metadata/DependencyInjectionParameterBinding.cs", + "src/EFCore/Metadata/IConventionProperty.cs", + "src/EFCore/Metadata/DependencyInjectionMethodParameterBinding.cs" + ] + }, + "aider_4096": { + "file_count": 70, + "tokens": 3401, + "recall": 0.1, + "precision": 0.014 + }, + "aider_8192": { + "file_count": 135, + "tokens": 7557, + "recall": 0.1, + "precision": 0.007 + } + }, + { + "file": "src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs", + "archetype": "CLI/Command", + "indegree": 33, + "outdegree": 15, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 4, + "confidence": 1, + "tokens": 2036, + "total_files": 29, + "must_read_files": [ + "src/ef/Json.cs", + "src/EFCore/Infrastructure/Internal/ILazyLoaderFactory.cs", + "src/EFCore/Metadata/Internal/IMemberClassifier.cs", + "src/EFCore/Internal/ICollectionLoader`.cs", + "src/EFCore/Diagnostics/Internal/DelegatingDbContextLogger.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosExecutionStrategy.cs", + "src/EFCore.Cosmos/Storage/Internal/ByteArrayConverter.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeOnlyReaderWriter.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeSpanReaderWriter.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs" + ] + }, + "aider_4096": { + "file_count": 69, + "tokens": 3379, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 161, + "tokens": 8884, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Relational/Diagnostics/CommandErrorEventData.cs", + "archetype": "Event/Message", + "indegree": 49, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 0, + "confidence": 1, + "tokens": 2051, + "total_files": 25, + "must_read_files": [ + "src/EFCore.Relational/Diagnostics/BatchEventData.cs", + "src/EFCore.Relational/Diagnostics/ColumnsEventData.cs", + "src/EFCore.Relational/Diagnostics/CommandCorrelatedEventData.cs", + "src/EFCore.Relational/Diagnostics/CommandEndEventData.cs", + "src/EFCore.Relational/Diagnostics/CommandEventData.cs", + "src/EFCore.Relational/Diagnostics/CommandExecutedEventData.cs", + "src/EFCore.Relational/Diagnostics/CommandSource.cs", + "src/EFCore.Relational/Diagnostics/ConnectionCreatedEventData.cs", + "src/EFCore.Relational/Diagnostics/ConnectionCreatingEventData.cs", + "src/EFCore.Relational/Diagnostics/ConnectionEndEventData.cs" + ] + }, + "aider_4096": { + "file_count": 79, + "tokens": 3980, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 133, + "tokens": 7407, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.Interfaces.cs", + "archetype": "Interface/Contract", + "indegree": 20, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 5, + "confidence": 1, + "tokens": 1942, + "total_files": 30, + "must_read_files": [ + "src/EFCore.Design/Scaffolding/Internal/CSharpEntityTypeGenerator.Interfaces.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpNamer.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpUniqueNamer.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpUtilities.cs", + "src/EFCore.Design/Scaffolding/Internal/CallContext.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpEntityTypeGenerator.cs", + "src/EFCore.Design/Scaffolding/Internal/CandidateNamingService.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpModelGenerator.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs" + ] + }, + "aider_4096": { + "file_count": 68, + "tokens": 3311, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 159, + "tokens": 8827, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Relational/Query/SqlExpressions/ITableBasedExpression.cs", + "archetype": "Orchestrator", + "indegree": 154, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 3, + "confidence": 1, + "tokens": 1277, + "total_files": 28, + "must_read_files": [ + "src/EFCore.Relational/Query/SqlExpressions/AtTimeZoneExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/CaseExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/CaseWhenClause.cs", + "src/EFCore.Relational/Query/SqlExpressions/CollateExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/ColumnExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/ColumnValueSetter.cs", + "src/EFCore.Relational/Query/SqlExpressions/CrossApplyExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/CrossJoinExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/DeleteExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/DistinctExpression.cs" + ] + }, + "aider_4096": { + "file_count": 81, + "tokens": 4084, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 131, + "tokens": 7493, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "test/EFCore.SqlServer.FunctionalTests/ComplexTypesTrackingSqlServerTest.cs", + "archetype": "Hotspot", + "indegree": 0, + "outdegree": 8, + "cc": { + "must_read_count": 8, + "likely_modify_count": 0, + "test_count": 0, + "confidence": 1, + "tokens": 700, + "total_files": 8, + "must_read_files": [ + "test/EFCore.SqlServer.FunctionalTests/JsonTypesSqlServerTestBase.cs", + "test/EFCore.SqlServer.FunctionalTests/SqlServerFixture.cs", + "test/EFCore.SqlServer.FunctionalTests/F1SqlServerFixture.cs", + "test/EFCore.SqlServer.FunctionalTests/ManyToManyTrackingSqlServerTestBase.cs", + "test/EFCore.SqlServer.FunctionalTests/QueryExpressionInterceptionSqlServerTestBase.cs", + "test/EFCore.SqlServer.FunctionalTests/SpatialSqlServerFixture.cs", + "test/EFCore.SqlServer.FunctionalTests/StoreGeneratedSqlServerTestBase.cs", + "test/EFCore.SqlServer.FunctionalTests/SqlServerValueGenerationScenariosTestBase.cs" + ] + }, + "aider_4096": { + "file_count": 67, + "tokens": 3279, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 133, + "tokens": 7473, + "recall": 0.0, + "precision": 0.0 + } + } + ], + "repomix_tokens": 23234107, + "summary": { + "avg_cc_tokens": 1420, + "avg_cc_files": 22.1, + "avg_aider_4k_recall": 0.024, + "avg_aider_8k_recall": 0.029, + "avg_aider_4k_precision": 0.003, + "avg_aider_8k_precision": 0.002 + } } }, "aggregate": { - "total_files_analyzed": 51, - "total_repos": 6, - "avg_cc_tokens": 1091, - "avg_cc_files": 17.0, - "avg_aider_4k_recall": 0.182, - "avg_aider_8k_recall": 0.229, - "avg_aider_4k_precision": 0.021, - "avg_aider_8k_precision": 0.015 + "total_files_analyzed": 68, + "total_repos": 7, + "avg_cc_tokens": 1174, + "avg_cc_files": 18.3, + "avg_aider_4k_recall": 0.139, + "avg_aider_8k_recall": 0.175, + "avg_aider_4k_precision": 0.016, + "avg_aider_8k_precision": 0.011 } } \ No newline at end of file diff --git a/benchmarks/methodology.md b/benchmarks/methodology.md index 5d89b5e..fb4a4d6 100644 --- a/benchmarks/methodology.md +++ b/benchmarks/methodology.md @@ -24,6 +24,7 @@ Repos were cloned with full git history on February 23, 2026. | [Tokio](https://github.com/tokio-rs/tokio) | Rust | 763 | 1,454 | A (3.79) | Tier 2: validated CC output | | [Terraform](https://github.com/hashicorp/terraform) | Go | 1,885 | 94,417 | A (3.78) | Tier 2: validated CC output | | [Zulip](https://github.com/zulip/zulip) | Python/TS | 2,638 | 10,087 | A (3.96) | Tier 2: validated CC output | +| [EF Core](https://github.com/dotnet/efcore) | C# | 5,708 | 47,206 | A (3.85) | Tier 2: validated CC output | | [Spring Boot](https://github.com/spring-projects/spring-boot) | Java | 7,978 | 15,658 | A (3.76) | Tier 2: validated CC output | ## File Selection @@ -239,6 +240,31 @@ The only non-zero results are for `tfdiags/compare.go` (a high fan-in utility wi Zulip is a mixed Python/TypeScript repo. Aider performs better on the Python files (which use standard imports that produce tree-sitter edges) than the TypeScript ones. Best result: `statuspage/view.py` at 57%, a small webhook handler with well-known imports. +### EF Core (C#, 5,708 files) + +| File | Archetype | CC must_read | Aider@4K Recall | Aider@8K Recall | CC Tokens | +|------|-----------|:-------:|:----------:|:----------:|:--------:| +| `SqlServerServiceCollectionExtensions.cs` | Service | 10 | 0% | 0% | 1,317 | +| `CountryRegion.cs` | Model | 10 | 0% | 10% | 2,224 | +| `IMemberTranslatorPlugin.cs` | Plugin | 10 | 0% | 0% | 2,151 | +| `IJsonValueReaderWriterSource.cs` | Utility | 10 | 0% | 0% | 1,585 | +| `ViewColumnBuilder.cs` | Endpoint | 10 | 0% | 0% | 1,160 | +| `SessionTokenStorageFactory.cs` | Auth | 10 | 10% | 10% | 1,080 | +| `RelationalConverterMappingHints.cs` | Leaf | 0 | 0% | 0% | 138 | +| `ConfigurationSourceExtensions.cs` | Config | 10 | 0% | 0% | 1,705 | +| `DbSetOperationTests.cs` | Test | 1 | 0% | 0% | 398 | +| `MigrationsOperations.cs` | Migration | 10 | 10% | 10% | 1,140 | +| `AdHocMapper.cs` | Serialization | 10 | 10% | 10% | 1,490 | +| `DefaultValueBinding.cs` | Error | 10 | 10% | 10% | 1,759 | +| `CosmosClientWrapper.cs` | CLI | 10 | 0% | 0% | 2,036 | +| `CommandErrorEventData.cs` | Event | 10 | 0% | 0% | 2,051 | +| `CSharpDbContextGenerator.Interfaces.cs` | Interface | 10 | 0% | 0% | 1,942 | +| `ITableBasedExpression.cs` | Orchestrator | 10 | 0% | 0% | 1,277 | +| `ComplexTypesTrackingSqlServerTest.cs` | Hotspot | 8 | 0% | 0% | 700 | +| **Average** | | **8.8** | **2%** | **3%** | **1,420** | + +C# uses namespace-level `using` directives that Aider's tree-sitter parser cannot resolve to files. At 5,708 files, Aider outputs 126–161 files per query (its tree-sitter-based global ranking) but only 2–3% overlap with actual dependencies. The few hits (10% recall on 4 files) come from files that happen to appear in Aider's global ranking by coincidence, not because it traced the import path. Contextception resolves namespaces via `.csproj` project detection, namespace-to-directory mapping, and same-namespace sibling discovery. + ### Spring Boot (Java, 7,978 files) | File | Archetype | CC must_read | Aider@4K Recall | Aider@8K Recall | CC Tokens | @@ -269,6 +295,7 @@ Complete failure. Aider outputs only 10–19 files per analysis (compared to 60 | Tokio | 763 | 0% | 0% | 1% | 0% | | Terraform | 1,885 | 3% | 0% | 8% | 0% | | Zulip | 2,638 | 21% | 20% | 27% | 25% | +| EF Core | 5,708 | 2% | 0% | 3% | 0% | | Spring Boot | 7,978 | 0% | 0% | 0% | 0% | Medians tell a starker story than means: outside httpx, Aider's median recall is 0–25%. @@ -290,16 +317,16 @@ CC averages **91–234 tokens per relevant must_read file**. Total output is 3 | Metric | CC | Aider@4K | Aider@8K | |--------|---:|---------:|---------:| -| Avg recall (all 51 files) | 100%† | 18% | 23% | -| Avg precision (all 51 files) | 100%† | 2.1% | 1.5% | -| Avg tokens per analysis | 1,091 | 3,600 | 7,200 | -| Languages with >0% recall | 4/4 | 2/4 | 2/4 | +| Avg recall (all 68 files) | 100%† | 14% | 18% | +| Avg precision (all 68 files) | 100%† | 1.6% | 1.2% | +| Avg tokens per analysis | 1,174 | 3,600 | 7,200 | +| Languages with >0% recall | 5/5 | 2/5 | 3/5 | † Against own output; see Tier 2 ground truth caveat. --- -## Why Aider Gets 0% on Go, Java, and Rust +## Why Aider Gets 0% on Go, Java, Rust, and C# Aider uses tree-sitter to parse source files and extract definitions (classes, functions, methods) and references (identifiers used). It then builds a graph and applies PageRank to select the most "important" files. @@ -314,8 +341,11 @@ The critical gap: **tree-sitter parses syntax, not semantics**. It doesn't resol | **Rust** | `mod` declarations | `mod write_guard;` → `write_guard.rs` | | **Rust** | `crate::` path resolution | `use crate::sync::batch_semaphore` → which file? | | **Rust** | Cargo workspace resolution | Cross-crate dependencies via `Cargo.toml` | +| **C#** | Namespace → file mapping | `using Microsoft.EntityFrameworkCore` → which files? | +| **C#** | .csproj project structure | Multi-project solutions with dotted directory names | +| **C#** | Same-namespace visibility | Files in the same directory share implicit type visibility | -Contextception resolves all of these via language-specific resolvers (Go: go.mod/go.work, Java: package conventions, Rust: Cargo.toml + mod tree). +Contextception resolves all of these via language-specific resolvers (Go: go.mod/go.work, Java: package conventions, Rust: Cargo.toml + mod tree, C#: .csproj detection + namespace-to-directory mapping). For Python and TypeScript, Aider fares better because tree-sitter can extract import paths that are more directly mappable to files — though it still misses tsconfig paths, workspace packages, and Python package-relative imports. @@ -325,7 +355,7 @@ For Python and TypeScript, Aider fares better because tree-sitter can extract im 1. **Aider's intended use case.** Aider's repo-map is designed as internal context for its own LLM-based editing workflow. It provides code signatures (class/function definitions) alongside file references, which is valuable for an LLM that needs to understand APIs. We're evaluating the file selection aspect only, ignoring the signature content that makes Aider's output useful within its own system. -2. **Contextception as ground truth.** For 5 of 6 repos, CC's output is the ground truth. This makes CC's recall/precision numbers meaningless in isolation. The comparison is still valid because Aider's recall is measured against the same ground truth — if CC's ground truth is wrong, Aider's numbers would also be wrong (potentially in Aider's favor if CC over-includes files). +2. **Contextception as ground truth.** For 6 of 7 repos, CC's output is the ground truth. This makes CC's recall/precision numbers meaningless in isolation. The comparison is still valid because Aider's recall is measured against the same ground truth — if CC's ground truth is wrong, Aider's numbers would also be wrong (potentially in Aider's favor if CC over-includes files). 3. **Composite score bias.** The 4-dimension rubric awards points for explainability and actionability. Aider's repo-map is a flat file list by design — it can't score well on these dimensions regardless of file selection quality. The recall/precision dimensions are the fair comparison. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3fa660..5bd024d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,6 +61,7 @@ An `ImportFact` represents one import statement: the raw specifier, line number, | Go | Regex | Standard library filtering via known packages | | Java | Regex | Package imports, standard library filtering | | Rust | Regex | `use`, `mod`, `extern crate`, multi-line imports | +| C# | Regex | `using` directives, `global using`, `using static`, aliases | **Adding a new language:** @@ -86,6 +87,7 @@ Each language has unique resolution rules: - **Go** — go.mod + go.work, same-package sibling discovery - **Java** — package-to-directory mapping, mirror-directory test discovery - **Rust** — Cargo workspaces, mod.rs, crate/super/self paths +- **C#** — .csproj project detection, namespace-to-file mapping, filename search fallback Unresolved imports (external packages, dynamic imports) are stored separately and contribute to the confidence score. diff --git a/docs/features.md b/docs/features.md index 6e88b8f..669940f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -2,7 +2,7 @@ Complete reference for Contextception's capabilities, output format, and configuration options. -**Schema version:** 3.2 | **Languages:** Python, TypeScript/JavaScript, Go, Java, Rust +**Schema version:** 3.2 | **Languages:** Python, TypeScript/JavaScript, Go, Java, Rust, C# --- @@ -140,7 +140,7 @@ The score combines four components: ### Evidence-Gated Same-Package Filtering -Same-package siblings (Go, Java, Rust) are only included in `must_read` if they have structural evidence: +Same-package siblings (Go, Java, Rust, C#) are only included in `must_read` if they have structural evidence: - Direct import/call edge - Co-change frequency >= 2 - Filename prefix match @@ -366,6 +366,23 @@ Compact labels on `likely_modify` and `related` entries: **Definitions extracted:** Functions, structs, enums, traits, type aliases, constants/statics. +### C# + +**Extractor:** Regex | **Extensions:** `.cs` + +| Import pattern | Example | +|---------------|---------| +| Namespace using | `using System.Collections.Generic;` | +| Static using | `using static System.Math;` | +| Alias using | `using Dict = System.Collections.Generic.Dictionary;` | +| Global using | `global using System.Linq;` | +| Global static using | `global using static System.Console;` | +| Global alias using | `global using Env = System.Environment;` | + +**Resolution:** `.csproj` project detection for source root discovery, namespace-to-file path mapping, filename search fallback for non-conventional layouts. Same-namespace sibling discovery. + +**Definitions extracted:** Classes, interfaces, structs, enums, records, methods, properties, delegates, constants/readonly fields. + --- ## CLI Reference @@ -671,3 +688,5 @@ The update check can also be disabled per-invocation with `--no-update-check` or | String-computed imports | All | Import paths constructed at runtime cannot be resolved | | No macro expansion | Rust | `macro_rules!` and procedural macros not analyzed | | No annotation processing | Java | Annotation-based dependencies not detected | +| No attribute processing | C# | Attribute-based dependencies (e.g., `[DependsOn]`) not detected | +| Namespace-level imports only | C# | `using` directives import namespaces, not individual types — resolved to representative files | diff --git a/docs/mcp-tutorial.md b/docs/mcp-tutorial.md index d8d4228..f6303d9 100644 --- a/docs/mcp-tutorial.md +++ b/docs/mcp-tutorial.md @@ -25,7 +25,7 @@ contextception --version ### Supported languages -Your project must use one or more of: Python, TypeScript/JavaScript, Go, Java, Rust. +Your project must use one or more of: Python, TypeScript/JavaScript, Go, Java, Rust, C#. ### MCP-compatible agent @@ -270,7 +270,7 @@ Check blast_radius to assess risk. For a global instruction that applies to all projects, add to `~/.claude/CLAUDE.md`: ``` -Use contextception MCP tools in repos with Python, TypeScript/JavaScript, Go, Java, or Rust code. +Use contextception MCP tools in repos with Python, TypeScript/JavaScript, Go, Java, Rust, or C# code. Before modifying a file, call get_context on it to understand its dependency context. ``` @@ -347,7 +347,7 @@ Then register it in `~/.claude/settings.json`: } ``` -This fires before every file edit, reminding the agent to check dependencies first. It skips non-code files (markdown, JSON, config) and only triggers for the five supported languages. +This fires before every file edit, reminding the agent to check dependencies first. It skips non-code files (markdown, JSON, config) and only triggers for the six supported languages. ### Use get_context for deleted files too diff --git a/integrations/README.md b/integrations/README.md index 2c5f119..24cbc5f 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -152,7 +152,7 @@ All integrations expose the same nine tools: ## Supported Languages -Contextception supports repositories using: Python, TypeScript/JavaScript, Go, Java, Rust. +Contextception supports repositories using: Python, TypeScript/JavaScript, Go, Java, Rust, C#. ## Slash Commands diff --git a/integrations/claude-code/CLAUDE.md b/integrations/claude-code/CLAUDE.md index 1740e64..25918f6 100644 --- a/integrations/claude-code/CLAUDE.md +++ b/integrations/claude-code/CLAUDE.md @@ -19,7 +19,7 @@ Add to your `~/.claude.json` or project `.claude/settings.json`: ## Instructions for Claude Code -Use contextception MCP tools in repos with Python, TypeScript/JavaScript, Go, Java, or Rust code. Before modifying a file, call `get_context` on it to understand its dependency context. The tool auto-indexes and stays current automatically. If `.contextception/` is not in `.gitignore`, add it. +Use contextception MCP tools in repos with Python, TypeScript/JavaScript, Go, Java, Rust, or C# code. Before modifying a file, call `get_context` on it to understand its dependency context. The tool auto-indexes and stays current automatically. If `.contextception/` is not in `.gitignore`, add it. ### When to use each tool diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 2b18af8..134263c 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -167,7 +167,7 @@ func (a *Analyzer) Analyze(filePath string) (*model.AnalysisOutput, error) { entry.Direction = "imports" case sc.IsImporter: entry.Direction = "imported_by" - case sc.IsSamePackageSibling || sc.IsGoSamePackage || sc.IsJavaSamePackage || sc.IsRustSameModule: + case sc.IsSamePackageSibling || sc.IsGoSamePackage || sc.IsJavaSamePackage || sc.IsRustSameModule || sc.IsCSharpSameNamespace: entry.Direction = "same_package" } entry.Role = classify.ClassifyRole( diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index 4c0f398..6fb7d27 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -4636,4 +4636,180 @@ func TestNestedTestDirMatchNormStillRequiresDirMatch(t *testing.T) { } } +// --- C# helper function tests --- + +func TestCsharpPrefixStemMatch(t *testing.T) { + tests := []struct { + testStem string + subjectStem string + want bool + }{ + // Documented examples: shorter test name matches longer impl name. + {"AutomaticLineEnder", "AutomaticLineEnderCommandHandler", true}, + {"MakeLocalFunctionStatic", "MakeLocalFunctionStaticHelper", true}, + + // Two CamelCase words, valid boundary. + {"AutomaticLine", "AutomaticLineEnder", true}, + + // Test stem >= subject stem: never matches. + {"AutomaticLineEnderCommandHandler", "AutomaticLineEnder", false}, + {"FooBar", "FooBar", false}, // exact match + + // Only 1 CamelCase word in prefix: too short, rejected. + {"Auto", "AutomaticLineEnder", false}, + {"Foo", "FooBar", false}, + + // Not at CamelCase boundary. + {"FooBa", "FooBar", false}, + {"AutomaticLi", "AutomaticLineEnder", false}, + + // Empty stems. + {"", "FooBar", false}, + {"FooBar", "", false}, + } + for _, tt := range tests { + t.Run(tt.testStem+"_vs_"+tt.subjectStem, func(t *testing.T) { + if got := csharpPrefixStemMatch(tt.testStem, tt.subjectStem); got != tt.want { + t.Errorf("csharpPrefixStemMatch(%q, %q) = %v, want %v", + tt.testStem, tt.subjectStem, got, tt.want) + } + }) + } +} + +func TestIsCSharpTestProjectMirror(t *testing.T) { + tests := []struct { + name string + sourceDir string + testDir string + want bool + }{ + // Standard dotted project → dotted test project. + {"EF Core .Tests suffix", "src/EFCore.Design/Design/Internal", "test/EFCore.Design.Tests/Design/Internal", true}, + {"EF Core .Test suffix", "src/EFCore.Design/Design", "test/EFCore.Design.Test/Design", true}, + {"UnitTests suffix", "src/EFCore.Design/Design", "test/EFCore.Design.UnitTests/Design", true}, + {"IntegrationTests suffix", "src/EFCore.Design/Design", "test/EFCore.Design.IntegrationTests/Design", true}, + + // Jellyfin renaming: Emby.* source → Jellyfin.*.Tests test. + {"Jellyfin renamed project", "Emby.Server.Implementations/Session", "tests/Jellyfin.Server.Implementations.Tests/Session", true}, + + // Roslyn-style non-dotted: CSharp → CSharpTest. + {"Roslyn CSharpTest", "src/EditorFeatures/CSharp/Foo", "src/EditorFeatures/CSharpTest/Foo", true}, + {"Non-dotted Tests suffix", "src/Features/Core/Portable", "src/Features/CoreTests/Portable", true}, + + // Same project: not a mirror. + {"Same project", "src/EFCore.Design/Design", "src/EFCore.Design/Design", false}, + + // Unrelated projects. + {"Unrelated dotted", "src/Foo.Bar/Baz", "test/Qux.Quux.Tests/Baz", false}, + {"Unrelated non-dotted", "src/Foo/Bar", "tests/Baz/Qux", false}, + + // No dots, no match. + {"No dots no match", "MyApp/Services", "MyApp/Services", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isCSharpTestProjectMirror(tt.sourceDir, tt.testDir); got != tt.want { + t.Errorf("isCSharpTestProjectMirror(%q, %q) = %v, want %v", + tt.sourceDir, tt.testDir, got, tt.want) + } + }) + } +} + +func TestHasTestDirComponentCSharp(t *testing.T) { + tests := []struct { + dir string + want bool + }{ + // Standard dotted C# test project directories. + {"test/EFCore.Design.Tests/Design", true}, + {"tests/Jellyfin.Api.Tests/Auth", true}, + {"src/MyLib.Tests/Unit", true}, + {"MyLib.Test", true}, + + // Roslyn-style CamelCase suffix (CSharpTest, VisualBasicTest). + {"src/EditorFeatures/CSharpTest/Foo", true}, + {"src/Features/CoreTests/Portable", true}, + + // Case-insensitive test/tests. + {"Test/unit", true}, + {"TESTS/integration", true}, + + // Non-test directories with dots (should not match). + {"src/MyApp.Core/Services", false}, + {"src/Microsoft.Extensions.DependencyInjection/Internal", false}, + } + for _, tt := range tests { + if got := hasTestDirComponent(tt.dir); got != tt.want { + t.Errorf("hasTestDirComponent(%q) = %v, want %v", tt.dir, got, tt.want) + } + } +} + +func TestExtractTestStemCSharp(t *testing.T) { + tests := []struct { + base string + want string + }{ + // C# test suffixes. + {"FooTests.cs", "Foo"}, + {"FooTest.cs", "Foo"}, + {"FooSpec.cs", "Foo"}, + + // C# test prefix. + {"TestFoo.cs", "Foo"}, + + // Order matters: Tests stripped before Test. + {"FooTests.cs", "Foo"}, + + // Edge cases. + {"Foo.cs", ""}, // not a test file pattern + {"Test.cs", ""}, // stem after stripping prefix is empty + {"Tests.cs", ""}, // stem after stripping suffix is empty + {"TestBase.cs", "Base"}, + } + for _, tt := range tests { + t.Run(tt.base, func(t *testing.T) { + if got := extractTestStem(tt.base); got != tt.want { + t.Errorf("extractTestStem(%q) = %q, want %q", tt.base, got, tt.want) + } + }) + } +} + +func TestCsharpProjectInfo(t *testing.T) { + tests := []struct { + filePath string + wantProject string + wantRelPath string + }{ + // Dotted project directory (deepest wins). + {"src/EFCore.Design/Design/Internal/Foo.cs", "EFCore.Design", "Design/Internal"}, + {"Emby.Server.Implementations/Session/SessionManager.cs", "Emby.Server.Implementations", "Session"}, + {"tests/Jellyfin.Api.Tests/Auth/HandlerTests.cs", "Jellyfin.Api.Tests", "Auth"}, + + // Dotted project at root level. + {"MyApp.Core/Services/UserService.cs", "MyApp.Core", "Services"}, + + // No dotted dirs: fallback to first non-root component. + {"src/Compilers/CSharp/Portable/Syntax/SyntaxFacts.cs", "Compilers", "CSharp/Portable/Syntax"}, + + // File at root: no project info. + {"Foo.cs", "", ""}, + + // File directly under src/: "src" is skipped as common root, no project. + {"src/Foo.cs", "", ""}, + } + for _, tt := range tests { + t.Run(tt.filePath, func(t *testing.T) { + gotProject, gotRel := csharpProjectInfo(tt.filePath) + if gotProject != tt.wantProject || gotRel != tt.wantRelPath { + t.Errorf("csharpProjectInfo(%q) = (%q, %q), want (%q, %q)", + tt.filePath, gotProject, gotRel, tt.wantProject, tt.wantRelPath) + } + }) + } +} + // --- pythonTestDirs tests --- diff --git a/internal/analyzer/categorizer.go b/internal/analyzer/categorizer.go index dd0568a..48411ee 100644 --- a/internal/analyzer/categorizer.go +++ b/internal/analyzer/categorizer.go @@ -4,6 +4,7 @@ import ( "path" "sort" "strings" + "unicode" "github.com/kehoej/contextception/internal/classify" "github.com/kehoej/contextception/internal/config" @@ -214,7 +215,12 @@ func categorize(scored []scoredCandidate, subject string, cfg *config.Config, ca } if sc.Distance <= 1 { - if sc.IsImport { + // C# same-namespace siblings route to samePackageSiblings even if they have + // import edges, because C# using directives import namespaces (not files), + // so the "import edge" is to an arbitrary representative file, not a direct dependency. + if sc.IsCSharpSameNamespace { + samePackageSiblings = append(samePackageSiblings, sc.Path) + } else if sc.IsImport { forwardImports = append(forwardImports, sc.Path) } else if sc.IsSamePackageSibling || sc.IsGoSamePackage || sc.IsJavaSamePackage || sc.IsRustSameModule { samePackageSiblings = append(samePackageSiblings, sc.Path) @@ -563,10 +569,21 @@ func filterTestCandidates(candidates []scoredCandidate, subject string, mustRead importsSubject := sc.IsImporter subjectStemMatch := testStem != "" && subjectStem != "" && testStem == subjectStem + // C# prefix stem matching: test stems often drop class suffixes. + // E.g., AutomaticLineEnderCommandHandler → AutomaticLineEnderTests (stem: AutomaticLineEnder). + // Match when the test stem is a CamelCase prefix of the subject stem. + if !subjectStemMatch && strings.HasSuffix(subject, ".cs") && testStem != "" && subjectStem != "" { + subjectStemMatch = csharpPrefixStemMatch(testStem, subjectStem) + } + // I-017: Cross-directory stem matches require the test to import the subject. // Without this, generic stems like "entity" or "config" match hundreds of // unrelated test files in large repos with repeated module names. - if subjectStemMatch && !sameDir && !testsSubdir { + // Exception: C# test projects (*.Tests/) use interface-based testing where + // tests reference interfaces, not implementations. Stem match alone suffices + // when the test lives in a recognized C# test project directory. + inCSharpTestProject := strings.HasSuffix(sc.Path, ".cs") && hasTestDirComponent(testDir) + if subjectStemMatch && !sameDir && !testsSubdir && !inCSharpTestProject { subjectStemMatch = importsSubject } @@ -693,11 +710,16 @@ func filterTestCandidates(candidates []scoredCandidate, subject string, mustRead javaMirrorDir := isJavaTestMirror(subjectDir, testDir) mirrorDirQualified := javaMirrorDir && (importsSubject || subjectStemMatch) + // C# test project mirror: source project ↔ test project directory. + // E.g., src/EFCore.Design/ ↔ test/EFCore.Design.Tests/ + csharpMirror := strings.HasSuffix(subject, ".cs") && isCSharpTestProjectMirror(subjectDir, testDir) + csharpMirrorQualified := csharpMirror && (importsSubject || subjectStemMatch) + // A test qualifies as "direct" when structural signals confirm it targets // the subject: co-located (same dir or __tests__/) with an import or stem // match, or stem match from any location. Importing the subject alone is // insufficient — many consumers import a file without being tests *for* it. - isDirect := sameDirQualified || testsSubdirQualified || subjectStemMatch || initMatch || initParentMatch || parentDirStemMatch || nestedTestDirMatch || nestedTestDirImporter || mirrorDirQualified + isDirect := sameDirQualified || testsSubdirQualified || subjectStemMatch || initMatch || initParentMatch || parentDirStemMatch || nestedTestDirMatch || nestedTestDirImporter || mirrorDirQualified || csharpMirrorQualified // Rust integration test classification: tests/ directory at crate root. if !isDirect && strings.HasSuffix(subject, ".rs") && !classify.IsTestFile(subject) { @@ -811,12 +833,26 @@ func filterTestCandidates(candidates []scoredCandidate, subject string, mustRead return result, totalBeforeCap } -// hasTestDirComponent returns true if any path component is "test" or "tests". +// hasTestDirComponent returns true if any path component is "test" or "tests", +// or matches C# test project patterns (*.Tests, *.Test). func hasTestDirComponent(dir string) bool { for _, part := range strings.Split(dir, "/") { - if part == "test" || part == "tests" { + lower := strings.ToLower(part) + if lower == "test" || lower == "tests" { return true } + // C# test project directories: Foo.Tests, Foo.Test, FooTest, FooTests + if strings.HasSuffix(part, ".Tests") || strings.HasSuffix(part, ".Test") { + return true + } + // Roslyn-style: CSharpTest, VisualBasicTest (CamelCase ending in Test/Tests) + if len(part) > 4 && (strings.HasSuffix(part, "Test") || strings.HasSuffix(part, "Tests")) { + // Ensure it's not just "Test" or "Tests" alone (already handled above). + prefix := strings.TrimSuffix(strings.TrimSuffix(part, "s"), "Test") + if len(prefix) > 0 && prefix[0] != '.' { + return true + } + } } return false } @@ -860,6 +896,22 @@ func extractTestStem(base string) string { return stem[4:] } } + // C#: FooTest.cs, FooTests.cs, TestFoo.cs, FooSpec.cs + if strings.HasSuffix(base, ".cs") { + stem := strings.TrimSuffix(base, ".cs") + if strings.HasSuffix(stem, "Tests") { + return stem[:len(stem)-5] + } + if strings.HasSuffix(stem, "Test") { + return stem[:len(stem)-4] + } + if strings.HasSuffix(stem, "Spec") { + return stem[:len(stem)-4] + } + if strings.HasPrefix(stem, "Test") { + return stem[4:] + } + } // Rust: foo_test.rs, test_bar.rs if strings.HasSuffix(base, "_test.rs") { return base[:len(base)-8] @@ -903,6 +955,36 @@ func normalizePyDirStem(stem string) string { return "" } +// csharpPrefixStemMatch checks if the test stem is a CamelCase prefix of the +// subject stem after stripping common C# class suffixes. This handles the common +// pattern where tests use a shorter name than the implementation: +// +// AutomaticLineEnderCommandHandler → AutomaticLineEnder (test stem) +// MakeLocalFunctionStaticHelper → MakeLocalFunctionStatic (test stem) +// +// Requires at least 2 CamelCase words in the prefix to avoid false positives. +func csharpPrefixStemMatch(testStem, subjectStem string) bool { + if len(testStem) >= len(subjectStem) { + return false + } + // The test stem must be a prefix of the subject stem at a CamelCase boundary. + if !strings.HasPrefix(subjectStem, testStem) { + return false + } + // Verify boundary: the character after the prefix must be uppercase (CamelCase word start). + rest := subjectStem[len(testStem):] + if len(rest) == 0 { + return false // exact match, not prefix + } + runes := []rune(rest) + if !unicode.IsUpper(runes[0]) { + return false + } + // Require ≥2 CamelCase words in the test stem to prevent overly short prefixes. + testParts := splitCamelCase(testStem) + return len(testParts) >= 2 +} + // normalizePyStem strips trailing digits from a Python filename stem. // Returns "" if no digits were stripped or result would be < 2 chars. // "dbapi2" → "dbapi", "sha256" → "sha", "v2" → "" (too short), "config" → "" @@ -960,6 +1042,87 @@ func isJavaTestMirror(sourceDir, testDir string) bool { return testDir == expectedTestDir } +// isCSharpTestProjectMirror detects C# test project directory patterns. +// Source projects map to test projects via naming conventions: +// +// src/EFCore.Design/Design/Internal → test/EFCore.Design.Tests/Design/Internal +// Emby.Server.Implementations/Session → tests/Jellyfin.Server.Implementations.Tests/Session +// src/EditorFeatures/CSharp/Foo → src/EditorFeatures/CSharpTest/Foo +// +// The function extracts the deepest dotted directory component from each path +// and checks if they share a project name relationship. +func isCSharpTestProjectMirror(sourceDir, testDir string) bool { + srcParts := strings.Split(sourceDir, "/") + testParts := strings.Split(testDir, "/") + + // Find deepest dotted component in each path. + srcProject := "" + for _, p := range srcParts { + if strings.Contains(p, ".") && p != "." { + srcProject = p + } + } + testProject := "" + for _, p := range testParts { + if strings.Contains(p, ".") && p != "." { + testProject = p + } + } + + if srcProject == "" || testProject == "" { + // Fallback: check for CSharp ↔ CSharpTest pattern (no dots). + for _, sp := range srcParts { + if sp == "src" || sp == "test" || sp == "tests" || sp == "." { + continue + } + for _, tp := range testParts { + if tp == sp+"Test" || tp == sp+"Tests" { + return true + } + } + } + return false + } + + if srcProject == testProject { + return false // same project, not a mirror + } + + // Check if testProject is srcProject + test suffix. + testSuffixes := []string{".Tests", ".Test", ".UnitTests", ".IntegrationTests", "Tests", "Test"} + for _, suffix := range testSuffixes { + if testProject == srcProject+suffix { + return true + } + } + + // Check if srcProject is a suffix of testProject (handles Jellyfin renaming). + // E.g., "Server.Implementations" in testProject "Jellyfin.Server.Implementations.Tests" + // matches source "Emby.Server.Implementations". + for _, suffix := range testSuffixes { + stripped := strings.TrimSuffix(testProject, suffix) + if stripped != testProject && stripped != "" { + // Check if source project shares a dotted suffix with stripped test project. + if strings.HasSuffix(stripped, srcProject) || strings.HasSuffix(srcProject, stripped) { + return true + } + // Check if the non-dotted parts match (e.g., both contain "Server.Implementations"). + srcDotParts := strings.Split(srcProject, ".") + strippedDotParts := strings.Split(stripped, ".") + if len(srcDotParts) >= 2 && len(strippedDotParts) >= 2 { + // Compare from the end: do the last N-1 components match? + srcTail := strings.Join(srcDotParts[1:], ".") + strippedTail := strings.Join(strippedDotParts[1:], ".") + if srcTail == strippedTail && srcTail != "" { + return true + } + } + } + } + + return false +} + // testMatchesMustReadStem returns true if the test file's stem matches // the stem (filename without extension) of any must_read file. func testMatchesMustReadStem(testPath string, mustReadPaths []string) bool { diff --git a/internal/analyzer/definitions.go b/internal/analyzer/definitions.go index aff6bf3..958c0a8 100644 --- a/internal/analyzer/definitions.go +++ b/internal/analyzer/definitions.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/kehoej/contextception/internal/extractor" + csextractor "github.com/kehoej/contextception/internal/extractor/csharp" goextractor "github.com/kehoej/contextception/internal/extractor/golang" pyextractor "github.com/kehoej/contextception/internal/extractor/python" tsextractor "github.com/kehoej/contextception/internal/extractor/typescript" @@ -19,11 +20,13 @@ func init() { py := pyextractor.New() ts := tsextractor.New() go_ := goextractor.New() + cs := csextractor.New() definitionExtractors = map[string]extractor.DefinitionExtractor{ ".py": py, ".ts": ts, ".tsx": ts, ".js": ts, ".jsx": ts, ".mts": ts, ".cts": ts, ".mjs": ts, ".cjs": ts, ".go": go_, + ".cs": cs, } } diff --git a/internal/analyzer/graph.go b/internal/analyzer/graph.go index 7ed1ce5..06adc45 100644 --- a/internal/analyzer/graph.go +++ b/internal/analyzer/graph.go @@ -29,6 +29,9 @@ type candidate struct { HasRustModulePrefix bool // Rust: shares underscore-delimited filename prefix with subject IsSmallRustModule bool // Rust: directory has ≤ 20 non-test .rs files HasInlineTests bool // Rust: file contains #[cfg(test)] inline test module + IsCSharpSameNamespace bool // C#: same-directory .cs file (same namespace) + HasCSharpClassPrefix bool // C#: shares a CamelCase class name prefix with subject + IsSmallCSharpNamespace bool // C#: directory has ≤ 20 non-test .cs files Signals db.SignalRow Churn float64 // normalized 0.0-1.0 CoChangeFreq int // raw co-change frequency with subject @@ -434,6 +437,57 @@ func collectCandidates(idx *db.Index, subject string, repoRoot string) ([]candid } } + // C# same-namespace discovery: C# namespaces provide implicit type visibility + // within the same directory, analogous to Java packages and Go packages. + if strings.HasSuffix(subject, ".cs") && !classify.IsTestFile(subject) { + subjectDir := path.Dir(subject) + siblings, err := idx.GetCSharpSiblingsInDir(subjectDir, subject) + if err == nil { + namespaceSize := len(siblings) + 1 + isSmallNs := namespaceSize <= maxPackageSize + subjectStem := strings.TrimSuffix(path.Base(subject), ".cs") + siblings = prioritizeByJavaClassPrefix(siblings, subject) // CamelCase prefix works for C# too + + // For large namespaces: only add class-prefix-matched siblings. + if !isSmallNs { + var filtered []string + for _, sf := range siblings { + sfStem := strings.TrimSuffix(path.Base(sf), ".cs") + if shareJavaClassPrefix(subjectStem, sfStem) { + filtered = append(filtered, sf) + } + } + siblings = filtered + } + + count := 0 + for _, sf := range siblings { + if _, exists := candidates[sf]; !exists { + candidates[sf] = &candidate{Path: sf, Distance: 1, IsCSharpSameNamespace: true} + count++ + if count >= maxSiblingCandidates { + break + } + } + } + + // Post-process: mark ALL C# candidates in subject's directory + // with IsCSharpSameNamespace/HasCSharpClassPrefix/IsSmallCSharpNamespace. + for _, c := range candidates { + if c.Path != subject && path.Dir(c.Path) == subjectDir && + strings.HasSuffix(c.Path, ".cs") && !classify.IsTestFile(c.Path) { + c.IsCSharpSameNamespace = true + c.IsSmallCSharpNamespace = isSmallNs + c.Distance = 1 + candidateStem := strings.TrimSuffix(path.Base(c.Path), ".cs") + if shareJavaClassPrefix(subjectStem, candidateStem) { + c.HasCSharpClassPrefix = true + } + } + } + } + } + // Python tests/ directory test discovery: Python projects commonly place tests // in a root-level tests/ directory (e.g., tests/test_configuration_utils.py), // which has no import edges back to the source files. Without this discovery, @@ -510,6 +564,113 @@ func collectCandidates(idx *db.Index, subject string, repoRoot string) ([]candid } } + // C# test discovery: C# test files live in separate .Tests project directories + // (e.g., tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs) + // with no import edges back to the source. We search the index for test files + // whose stem matches the subject stem, plus stems with common suffixes stripped + // (e.g., AutomaticLineEnderCommandHandler → AutomaticLineEnderTests.cs). + if strings.HasSuffix(subject, ".cs") && !classify.IsTestFile(subject) { + subjectStem := strings.TrimSuffix(path.Base(subject), ".cs") + if subjectStem != "" { + seen := map[string]bool{} + var testBasenames []string + addTestName := func(stem string) { + for _, name := range []string{ + stem + "Tests.cs", + stem + "Test.cs", + "Test" + stem + ".cs", + stem + "Spec.cs", + } { + if !seen[name] { + seen[name] = true + testBasenames = append(testBasenames, name) + } + } + } + + // Exact stem match. + addTestName(subjectStem) + + // Strip common C# class suffixes and search for shortened stems. + // E.g., AutomaticLineEnderCommandHandler → AutomaticLineEnder + // MakeLocalFunctionStaticHelper → MakeLocalFunctionStatic + csharpClassSuffixes := []string{ + "CommandHandler", "Handler", "Helpers", "Helper", + "Service", "Provider", "Manager", "Factory", + "Builder", "Adapter", "Wrapper", "Extensions", + "Repository", "Controller", "Options", "Configuration", + "Validator", "Converter", "Processor", "Client", + } + for _, suffix := range csharpClassSuffixes { + if stripped := strings.TrimSuffix(subjectStem, suffix); stripped != subjectStem && len(stripped) > 2 { + addTestName(stripped) + } + } + + // CamelCase prefix search: for multi-word stems, try progressively + // shorter prefixes. E.g., MakeLocalFunctionStaticHelper → + // MakeLocalFunctionStatic, MakeLocalFunction, MakeLocal. + parts := splitCamelCase(subjectStem) + if len(parts) >= 3 { + // Try dropping 1 and 2 trailing words. + for drop := 1; drop <= 2 && drop < len(parts)-1; drop++ { + prefix := strings.Join(parts[:len(parts)-drop], "") + if len(prefix) > 3 { + addTestName(prefix) + } + } + } + + testFiles, err := idx.GetTestFilesByName("", testBasenames) + if err == nil { + for _, tf := range testFiles { + if _, exists := candidates[tf]; !exists { + candidates[tf] = &candidate{Path: tf, Distance: 2} + } + } + } + } + } + + // C# test project mirror discovery: C# test projects (e.g., tests/MyLib.Tests/) + // mirror source projects (e.g., src/MyLib/) with test files in corresponding + // subdirectories. Like Java's src/main/java ↔ src/test/java pattern, but using + // .NET project naming conventions. We discover the source project name, find + // matching test project directories, and add test files as Distance=1 candidates. + if strings.HasSuffix(subject, ".cs") && !classify.IsTestFile(subject) { + if projectName, relPath := csharpProjectInfo(subject); projectName != "" { + testDirs, err := idx.FindCSharpTestDirs(projectName) + if err == nil { + const maxMirrorTests = 10 + for _, testDir := range testDirs { + // Look for test files in the mirrored subdirectory path. + var searchDir string + if relPath != "" { + searchDir = testDir + "/" + relPath + } else { + searchDir = testDir + } + testFiles, err := idx.GetTestFilesUnderDir(searchDir, ".cs") + if err != nil { + continue + } + count := 0 + for _, tf := range testFiles { + if classify.IsTestFile(tf) { + if _, exists := candidates[tf]; !exists { + candidates[tf] = &candidate{Path: tf, Distance: 1, IsImporter: true} + count++ + if count >= maxMirrorTests { + break + } + } + } + } + } + } + } + } + // Fetch signals for all candidates. allPaths := make([]string, 0, len(candidates)) for p := range candidates { @@ -879,8 +1040,8 @@ func filterSamePackageSiblings(candidates map[string]*candidate, subject string) if p == subject || c.Distance == 0 { continue } - // Only filter same-package siblings (Go, Java, Rust). - isSamePackage := c.IsSamePackageSibling || c.IsGoSamePackage || c.IsJavaSamePackage || c.IsRustSameModule + // Only filter same-package siblings (Go, Java, Rust, C#). + isSamePackage := c.IsSamePackageSibling || c.IsGoSamePackage || c.IsJavaSamePackage || c.IsRustSameModule || c.IsCSharpSameNamespace if !isSamePackage { continue } @@ -893,7 +1054,7 @@ func filterSamePackageSiblings(candidates map[string]*candidate, subject string) continue } // Keep if has prefix match. - if c.HasPrefixMatch || c.HasJavaClassPrefix || c.HasRustModulePrefix { + if c.HasPrefixMatch || c.HasJavaClassPrefix || c.HasRustModulePrefix || c.HasCSharpClassPrefix { continue } // No evidence: remove from candidates. @@ -901,6 +1062,56 @@ func filterSamePackageSiblings(candidates map[string]*candidate, subject string) } } +// csharpProjectInfo extracts the C# project name and the file's relative path +// within that project from a repo-relative file path. It identifies the project +// root as the deepest directory component that looks like a .NET project name +// (contains dots, e.g., "MediaBrowser.Controller" or "Orleans.Runtime"). +// For simple projects without dots, uses the first meaningful directory component. +// +// Examples: +// +// "src/EFCore.Design/Design/Internal/Foo.cs" → ("EFCore.Design", "Design/Internal") +// "Emby.Server.Implementations/Session/SessionManager.cs" → ("Emby.Server.Implementations", "Session") +// "src/Compilers/CSharp/Portable/Syntax/SyntaxFacts.cs" → ("CSharp", "Portable/Syntax") +// "tests/Jellyfin.Api.Tests/Auth/HandlerTests.cs" → ("Jellyfin.Api.Tests", "Auth") +func csharpProjectInfo(filePath string) (projectName, relPath string) { + dir := path.Dir(filePath) + parts := strings.Split(dir, "/") + + // Find the deepest directory component that looks like a project name (contains a dot). + bestIdx := -1 + for i, part := range parts { + if strings.Contains(part, ".") && part != "." { + bestIdx = i + } + } + + if bestIdx >= 0 { + projectName = parts[bestIdx] + if bestIdx+1 < len(parts) { + relPath = strings.Join(parts[bestIdx+1:], "/") + } + return projectName, relPath + } + + // Fallback: no dotted directory. Use heuristics for repos like Roslyn + // where projects are at paths like src/Compilers/CSharp/Portable/. + // Skip common root dirs and use the first "interesting" component. + for i, part := range parts { + if part == "src" || part == "test" || part == "tests" || part == "." { + continue + } + // Use this component as the project name. + projectName = part + if i+1 < len(parts) { + relPath = strings.Join(parts[i+1:], "/") + } + return projectName, relPath + } + + return "", "" +} + // enrichWithGitSignals populates churn and co-change data on candidates. // Failures are silently ignored (graceful degradation to structural-only). func enrichWithGitSignals(idx *db.Index, candidates map[string]*candidate, allPaths []string, subject string) { diff --git a/internal/analyzer/signals.go b/internal/analyzer/signals.go index 2dcbeee..71732e5 100644 --- a/internal/analyzer/signals.go +++ b/internal/analyzer/signals.go @@ -51,6 +51,14 @@ func likelyModifyConfidence(sc *scoredCandidate) string { if sc.IsRustSameModule && sc.CoChangeFreq >= 1 { return "high" } + // C# same-namespace file with class prefix match: structural evidence of tight coupling. + if sc.IsCSharpSameNamespace && sc.HasCSharpClassPrefix { + return "high" + } + // C# same-namespace file with co-change evidence: structural + behavioral = high. + if sc.IsCSharpSameNamespace && sc.CoChangeFreq >= 1 { + return "high" + } return "medium" } @@ -81,6 +89,11 @@ func isLikelyModify(sc *scoredCandidate) bool { if sc.IsRustSameModule && sc.Distance == 1 && (sc.HasRustModulePrefix || sc.IsSmallRustModule) { return true } + // C# same-namespace: implicit namespace visibility creates structural coupling. + // Only qualify if there's evidence of tight coupling (class prefix match, small namespace). + if sc.IsCSharpSameNamespace && sc.Distance == 1 && (sc.HasCSharpClassPrefix || sc.IsSmallCSharpNamespace) { + return true + } return false } @@ -120,12 +133,12 @@ func buildSignals(sc *scoredCandidate, cfg *config.Config, stableThresh int) []s if sc.IsImporter { signals = append(signals, "imported_by") } - if sc.IsGoSamePackage || sc.IsSamePackageSibling || sc.IsJavaSamePackage || sc.IsRustSameModule { + if sc.IsGoSamePackage || sc.IsSamePackageSibling || sc.IsJavaSamePackage || sc.IsRustSameModule || sc.IsCSharpSameNamespace { signals = append(signals, "same_package") } if sc.IsTransitiveCaller { signals = append(signals, "transitive_caller") - } else if sc.Distance == 2 && !sc.IsImport && !sc.IsImporter && !sc.IsSamePackageSibling && !sc.IsGoSamePackage && !sc.IsJavaSamePackage && !sc.IsRustSameModule { + } else if sc.Distance == 2 && !sc.IsImport && !sc.IsImporter && !sc.IsSamePackageSibling && !sc.IsGoSamePackage && !sc.IsJavaSamePackage && !sc.IsRustSameModule && !sc.IsCSharpSameNamespace { signals = append(signals, "two_hop") } diff --git a/internal/classify/classify.go b/internal/classify/classify.go index 245949f..4fc6a1e 100644 --- a/internal/classify/classify.go +++ b/internal/classify/classify.go @@ -42,6 +42,15 @@ func IsTestFile(path string) bool { } } + // C# patterns: *Test.cs, *Tests.cs, Test*.cs, *Spec.cs + if strings.HasSuffix(base, ".cs") { + stem := strings.TrimSuffix(base, ".cs") + if strings.HasSuffix(stem, "Test") || strings.HasSuffix(stem, "Tests") || + strings.HasPrefix(stem, "Test") || strings.HasSuffix(stem, "Spec") { + return true + } + } + // Rust patterns: *_test.rs (conventional), tests.rs (separate test module), and tests/ directory (below) if strings.HasSuffix(base, "_test.rs") { return true @@ -63,6 +72,17 @@ func IsTestFile(path string) bool { return true } + // C#: test project directories (*.Tests/, *.Test/). + if strings.Contains(path, ".Tests/") || strings.Contains(path, ".Test/") { + return true + } + // Also check if path starts with a test project directory. + for _, part := range strings.Split(path, "/") { + if strings.HasSuffix(part, ".Tests") || strings.HasSuffix(part, ".Test") { + return true + } + } + return false } diff --git a/internal/classify/classify_test.go b/internal/classify/classify_test.go index fe6b9e5..e93665e 100644 --- a/internal/classify/classify_test.go +++ b/internal/classify/classify_test.go @@ -27,6 +27,16 @@ func TestIsTestFile(t *testing.T) { {"src/utils/helpers.spec.ts", true}, {"__tests__/integration.ts", true}, {"src/__tests__/unit.tsx", true}, + // C# patterns + {"Services/FooTest.cs", true}, + {"Services/FooTests.cs", true}, + {"Services/TestFoo.cs", true}, + {"Services/FooSpec.cs", true}, + {"Services/Foo.cs", false}, + {"Services/Contest.cs", false}, // "Contest" contains "test" but isn't a test + {"Services/Manifest.cs", false}, // ends with "est" but isn't a test + {"test/EFCore.Tests/FooTest.cs", true}, + {"tests/MyLib.Tests/BarTests.cs", true}, // Non-test files {"mylib/api.py", false}, {"mylib/models.py", false}, diff --git a/internal/cli/extensions.go b/internal/cli/extensions.go index 4b36a7b..67a973f 100644 --- a/internal/cli/extensions.go +++ b/internal/cli/extensions.go @@ -10,6 +10,7 @@ import ( pyextractor "github.com/kehoej/contextception/internal/extractor/python" rustextractor "github.com/kehoej/contextception/internal/extractor/rust" tsextractor "github.com/kehoej/contextception/internal/extractor/typescript" + csharpextractor "github.com/kehoej/contextception/internal/extractor/csharp" "github.com/spf13/cobra" ) @@ -25,6 +26,7 @@ func newExtensionsCmd() *cobra.Command { goextractor.New(), javaextractor.New(), rustextractor.New(), + csharpextractor.New(), } var exts []string diff --git a/internal/cli/hook_check.go b/internal/cli/hook_check.go index fd9fcb4..db13ec5 100644 --- a/internal/cli/hook_check.go +++ b/internal/cli/hook_check.go @@ -13,6 +13,7 @@ import ( pyextractor "github.com/kehoej/contextception/internal/extractor/python" rustextractor "github.com/kehoej/contextception/internal/extractor/rust" tsextractor "github.com/kehoej/contextception/internal/extractor/typescript" + csharpextractor "github.com/kehoej/contextception/internal/extractor/csharp" "github.com/spf13/cobra" ) @@ -66,6 +67,7 @@ func supportedExtensions() []string { goextractor.New(), javaextractor.New(), rustextractor.New(), + csharpextractor.New(), } var exts []string diff --git a/internal/db/store.go b/internal/db/store.go index 2fa21bb..6074004 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -851,6 +851,12 @@ func (idx *Index) GetRustSiblingsInDir(dir, exclude string) ([]string, error) { return idx.getSiblingsInDir(dir, exclude, "rs", "", true) } +// GetCSharpSiblingsInDir returns non-test .cs files in the given directory, +// excluding the specified file and subdirectories. +func (idx *Index) GetCSharpSiblingsInDir(dir, exclude string) ([]string, error) { + return idx.getSiblingsInDir(dir, exclude, "cs", "", true) +} + // GetTestFilesInDir returns files in the given directory (not subdirectories) matching a suffix. // Used for Go (_test.go) and Rust test discovery. func (idx *Index) GetTestFilesInDir(dir, suffix string) ([]string, error) { @@ -884,6 +890,89 @@ func (idx *Index) GetTestFilesInDir(dir, suffix string) ([]string, error) { return results, rows.Err() } +// GetTestFilesUnderDir returns files under the given directory (including subdirectories) +// matching a suffix. Used for C# test project discovery where tests are organized +// in subdirectories mirroring the source project structure. +func (idx *Index) GetTestFilesUnderDir(dir, suffix string) ([]string, error) { + var pattern string + if dir == "." || dir == "" { + pattern = "%" + } else { + pattern = dir + "/%" + } + suffixPattern := "%" + suffix + + rows, err := idx.DB.Query( + `SELECT path FROM files WHERE path LIKE ? AND path LIKE ? ORDER BY path`, + pattern, suffixPattern, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + return nil, err + } + results = append(results, p) + } + return results, rows.Err() +} + +// FindCSharpTestDirs searches the index for directories that look like C# test +// projects matching the given source project name. It checks for patterns like: +// ProjectName.Tests/, ProjectName.Test/, ProjectName.UnitTests/, ProjectNameTests/. +// Returns repo-relative directory paths that contain indexed .cs files. +func (idx *Index) FindCSharpTestDirs(projectName string) ([]string, error) { + // Build test project name variants. + variants := []string{ + projectName + ".Tests", + projectName + ".Test", + projectName + ".UnitTests", + projectName + ".IntegrationTests", + projectName + "Tests", + projectName + "Test", + } + + // Query for directories that contain .cs files and match test project patterns. + dirSet := make(map[string]bool) + for _, variant := range variants { + // Look for files under any path containing this test project directory name. + patterns := []string{ + variant + "/%.cs", // root level: ProjectName.Tests/... + "%/" + variant + "/%.cs", // nested: test/ProjectName.Tests/... or tests/ProjectName.Tests/... + } + for _, pat := range patterns { + rows, err := idx.DB.Query(`SELECT DISTINCT path FROM files WHERE path LIKE ? LIMIT 1`, pat) + if err != nil { + continue + } + for rows.Next() { + var p string + if err := rows.Scan(&p); err == nil { + // Extract the directory up to and including the test project name. + varIdx := strings.Index(p, variant+"/") + if varIdx >= 0 { + dir := p[:varIdx+len(variant)] + dirSet[dir] = true + } + } + } + rows.Close() + } + } + + var dirs []string + for d := range dirSet { + dirs = append(dirs, d) + } + sort.Strings(dirs) + return dirs, nil +} + // GetTestFilesByName searches recursively under a directory prefix for files // matching any of the given basenames. Used for Python tests/ directory discovery // where test files (e.g., test_pipelines.py) live in a separate tests/ tree. diff --git a/internal/extractor/csharp/csharp.go b/internal/extractor/csharp/csharp.go new file mode 100644 index 0000000..03c6060 --- /dev/null +++ b/internal/extractor/csharp/csharp.go @@ -0,0 +1,240 @@ +// Package csharp implements import extraction for C# source files. +package csharp + +import ( + "regexp" + "strings" + + "github.com/kehoej/contextception/internal/extractor" + "github.com/kehoej/contextception/internal/model" +) + +var ( + // using System.Collections.Generic; + reUsing = regexp.MustCompile(`^using\s+([\w.]+)\s*;`) + + // using static System.Math; + reUsingStatic = regexp.MustCompile(`^using\s+static\s+([\w.]+)\s*;`) + + // using Alias = Some.Namespace; + reUsingAlias = regexp.MustCompile(`^using\s+(\w+)\s*=\s*([\w.]+)\s*;`) + + // global using System.Linq; / global using static System.Math; + reGlobalUsing = regexp.MustCompile(`^global\s+using\s+(static\s+)?([\w.]+)\s*;`) + + // global using Alias = Some.Namespace; + reGlobalUsingAlias = regexp.MustCompile(`^global\s+using\s+(\w+)\s*=\s*([\w.]+)\s*;`) + + // namespace Foo.Bar.Baz { ... } or namespace Foo.Bar.Baz; (file-scoped) + reNamespace = regexp.MustCompile(`^namespace\s+([\w.]+)`) + + // Class/interface/struct/enum/record declarations (stop parsing imports here). + reTypeDecl = regexp.MustCompile(`^(?:public\s+|protected\s+|private\s+|internal\s+|abstract\s+|sealed\s+|static\s+|partial\s+)*(?:class|interface|struct|enum|record)\s+(\w+)`) + + // Definition patterns for ExtractDefinitions. + reMethodDecl = regexp.MustCompile(`^(?:public\s+|protected\s+|private\s+|internal\s+|abstract\s+|sealed\s+|static\s+|virtual\s+|override\s+|async\s+|new\s+|extern\s+)*(?:[\w<>\[\],?\s]+)\s+(\w+)\s*\(`) + rePropertyDecl = regexp.MustCompile(`^(?:public\s+|protected\s+|private\s+|internal\s+|static\s+|virtual\s+|override\s+|abstract\s+|new\s+)*\w[\w<>\[\]?,\s]*\s+(\w+)\s*\{`) + reConstDecl = regexp.MustCompile(`^(?:public\s+|protected\s+|private\s+|internal\s+)?(?:static\s+)?(?:readonly\s+|const\s+)\S+\s+(\w+)\s*[=;]`) + reDelegateDecl = regexp.MustCompile(`^(?:public\s+|protected\s+|private\s+|internal\s+)?delegate\s+\S+\s+(\w+)\s*[\(<]`) +) + +// Extractor extracts import facts from C# source files. +type Extractor struct{} + +// New returns a new C# extractor. +func New() *Extractor { + return &Extractor{} +} + +func (e *Extractor) Language() string { return "csharp" } +func (e *Extractor) Clone() extractor.Extractor { return New() } +func (e *Extractor) Extensions() []string { return []string{".cs"} } + +// Extract parses C# source and returns all import facts. +func (e *Extractor) Extract(filePath string, content []byte) ([]model.ImportFact, error) { + lines := strings.Split(string(content), "\n") + var facts []model.ImportFact + inBlockComment := false + + var namespaceName string + + for i, rawLine := range lines { + lineNum := i + 1 + line := strings.TrimSpace(rawLine) + + // Handle block comments. + if inBlockComment { + if idx := strings.Index(line, "*/"); idx >= 0 { + inBlockComment = false + line = strings.TrimSpace(line[idx+2:]) + } else { + continue + } + } + if strings.HasPrefix(line, "/*") { + if !strings.Contains(line, "*/") { + inBlockComment = true + continue + } + // Single-line block comment. + idx := strings.Index(line, "*/") + line = strings.TrimSpace(line[idx+2:]) + } + + // Skip single-line comments. + if strings.HasPrefix(line, "//") { + continue + } + + // Skip empty lines. + if line == "" { + continue + } + + // Stop parsing imports at class/struct/interface/enum/record declaration. + if reTypeDecl.MatchString(line) { + break + } + + // Capture namespace declaration for same-namespace resolution. + if m := reNamespace.FindStringSubmatch(line); m != nil { + namespaceName = m[1] + continue + } + + // Global using alias: global using Alias = Some.Namespace; + if m := reGlobalUsingAlias.FindStringSubmatch(line); m != nil { + spec := m[2] // RHS namespace + facts = append(facts, model.ImportFact{ + Specifier: spec, + ImportType: "alias", + LineNumber: lineNum, + ImportedNames: []string{m[1]}, // alias name + }) + continue + } + + // Global using [static] Namespace; + if m := reGlobalUsing.FindStringSubmatch(line); m != nil { + isStatic := strings.TrimSpace(m[1]) == "static" + spec := m[2] + + importType := "absolute" + if isStatic { + importType = "static" + } + + facts = append(facts, model.ImportFact{ + Specifier: spec, + ImportType: importType, + LineNumber: lineNum, + ImportedNames: []string{"*"}, + }) + continue + } + + // using Alias = Some.Namespace; + if m := reUsingAlias.FindStringSubmatch(line); m != nil { + spec := m[2] // RHS namespace + facts = append(facts, model.ImportFact{ + Specifier: spec, + ImportType: "alias", + LineNumber: lineNum, + ImportedNames: []string{m[1]}, // alias name + }) + continue + } + + // using static System.Math; + if m := reUsingStatic.FindStringSubmatch(line); m != nil { + spec := m[1] + facts = append(facts, model.ImportFact{ + Specifier: spec, + ImportType: "static", + LineNumber: lineNum, + ImportedNames: []string{"*"}, + }) + continue + } + + // using System.Collections.Generic; + if m := reUsing.FindStringSubmatch(line); m != nil { + spec := m[1] + facts = append(facts, model.ImportFact{ + Specifier: spec, + ImportType: "absolute", + LineNumber: lineNum, + ImportedNames: []string{"*"}, + }) + continue + } + } + + // Emit synthetic same_namespace fact for C#'s implicit same-namespace type visibility. + if namespaceName != "" { + facts = append(facts, model.ImportFact{ + Specifier: namespaceName + ".*", + ImportType: "same_namespace", + LineNumber: 0, + }) + } + + return facts, nil +} + +// ExtractDefinitions returns signature lines for the given symbol names. +func (e *Extractor) ExtractDefinitions(content []byte, symbolNames []string) []string { + nameSet := extractor.BuildNameSet(symbolNames) + if nameSet == nil { + return nil + } + + lines := strings.Split(string(content), "\n") + var defs []string + + for _, rawLine := range lines { + trimmed := strings.TrimSpace(rawLine) + + // Class/interface/struct/enum/record declaration. + if m := reTypeDecl.FindStringSubmatch(trimmed); m != nil { + if nameSet[m[1]] { + defs = append(defs, extractor.TrimBodyOpener(trimmed)) + continue + } + } + + // Delegate declaration. + if m := reDelegateDecl.FindStringSubmatch(trimmed); m != nil { + if nameSet[m[1]] { + defs = append(defs, extractor.TruncateSig(trimmed, extractor.MaxSignatureLength)) + continue + } + } + + // Constant/readonly field. + if m := reConstDecl.FindStringSubmatch(trimmed); m != nil { + if nameSet[m[1]] { + defs = append(defs, extractor.TruncateSig(trimmed, extractor.MaxSignatureLength)) + continue + } + } + + // Property declaration (must come before method to avoid { false positive). + if m := rePropertyDecl.FindStringSubmatch(trimmed); m != nil { + if nameSet[m[1]] { + defs = append(defs, extractor.TrimBodyOpener(trimmed)) + continue + } + } + + // Method declaration. + if m := reMethodDecl.FindStringSubmatch(trimmed); m != nil { + if nameSet[m[1]] { + defs = append(defs, extractor.TrimBodyOpener(trimmed)) + continue + } + } + } + + return defs +} diff --git a/internal/extractor/csharp/csharp_test.go b/internal/extractor/csharp/csharp_test.go new file mode 100644 index 0000000..4670c1c --- /dev/null +++ b/internal/extractor/csharp/csharp_test.go @@ -0,0 +1,496 @@ +package csharp + +import ( + "testing" +) + +func TestSingleUsing(t *testing.T) { + ext := New() + facts, err := ext.Extract("Foo.cs", []byte(`namespace MyApp; + +using System.Collections.Generic; + +public class Foo {} +`)) + if err != nil { + t.Fatal(err) + } + // 1 import + 1 synthetic same_namespace = 2 facts + if len(facts) != 2 { + t.Fatalf("expected 2 facts, got %d", len(facts)) + } + if facts[0].Specifier != "System.Collections.Generic" { + t.Errorf("specifier = %q, want %q", facts[0].Specifier, "System.Collections.Generic") + } + if facts[0].ImportType != "absolute" { + t.Errorf("importType = %q, want %q", facts[0].ImportType, "absolute") + } + assertNames(t, facts[0].ImportedNames, []string{"*"}) + // Verify same_namespace fact. + if facts[1].ImportType != "same_namespace" { + t.Errorf("expected same_namespace fact, got %q", facts[1].ImportType) + } + if facts[1].Specifier != "MyApp.*" { + t.Errorf("same_namespace specifier = %q, want %q", facts[1].Specifier, "MyApp.*") + } +} + +func TestStaticUsing(t *testing.T) { + ext := New() + facts, err := ext.Extract("Foo.cs", []byte(`namespace MyApp; + +using static System.Math; + +public class Foo {} +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 2 { + t.Fatalf("expected 2 facts (1 import + 1 same_namespace), got %d", len(facts)) + } + if facts[0].Specifier != "System.Math" { + t.Errorf("specifier = %q, want %q", facts[0].Specifier, "System.Math") + } + if facts[0].ImportType != "static" { + t.Errorf("importType = %q, want %q", facts[0].ImportType, "static") + } + assertNames(t, facts[0].ImportedNames, []string{"*"}) +} + +func TestAliasUsing(t *testing.T) { + ext := New() + facts, err := ext.Extract("Foo.cs", []byte(`namespace MyApp; + +using Dict = System.Collections.Generic.Dictionary; + +public class Foo {} +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 2 { + t.Fatalf("expected 2 facts (1 import + 1 same_namespace), got %d", len(facts)) + } + if facts[0].Specifier != "System.Collections.Generic.Dictionary" { + t.Errorf("specifier = %q, want %q", facts[0].Specifier, "System.Collections.Generic.Dictionary") + } + if facts[0].ImportType != "alias" { + t.Errorf("importType = %q, want %q", facts[0].ImportType, "alias") + } + assertNames(t, facts[0].ImportedNames, []string{"Dict"}) +} + +func TestGlobalUsing(t *testing.T) { + ext := New() + facts, err := ext.Extract("GlobalUsings.cs", []byte(`global using System.Linq; +global using static System.Console; + +public class App {} +`)) + if err != nil { + t.Fatal(err) + } + // 2 imports, no namespace = 2 facts (no same_namespace fact since no namespace declared) + if len(facts) != 2 { + t.Fatalf("expected 2 facts, got %d", len(facts)) + } + if facts[0].Specifier != "System.Linq" { + t.Errorf("specifier = %q, want %q", facts[0].Specifier, "System.Linq") + } + if facts[0].ImportType != "absolute" { + t.Errorf("importType = %q, want %q", facts[0].ImportType, "absolute") + } + if facts[1].Specifier != "System.Console" { + t.Errorf("specifier = %q, want %q", facts[1].Specifier, "System.Console") + } + if facts[1].ImportType != "static" { + t.Errorf("importType = %q, want %q", facts[1].ImportType, "static") + } +} + +func TestGlobalUsingAlias(t *testing.T) { + ext := New() + facts, err := ext.Extract("GlobalUsings.cs", []byte(`global using Env = System.Environment; + +public class App {} +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 1 { + t.Fatalf("expected 1 fact, got %d", len(facts)) + } + if facts[0].Specifier != "System.Environment" { + t.Errorf("specifier = %q, want %q", facts[0].Specifier, "System.Environment") + } + if facts[0].ImportType != "alias" { + t.Errorf("importType = %q, want %q", facts[0].ImportType, "alias") + } + assertNames(t, facts[0].ImportedNames, []string{"Env"}) +} + +func TestMultipleUsings(t *testing.T) { + ext := New() + facts, err := ext.Extract("Foo.cs", []byte(`namespace MyApp.Services; + +using System.Collections.Generic; +using System.Linq; +using MyApp.Models; +using static System.Math; + +public class Foo {} +`)) + if err != nil { + t.Fatal(err) + } + // 4 imports + 1 same_namespace = 5 + if len(facts) != 5 { + t.Fatalf("expected 5 facts (4 imports + 1 same_namespace), got %d", len(facts)) + } + + specs := make(map[string]bool) + for _, f := range facts { + specs[f.Specifier] = true + } + for _, want := range []string{"System.Collections.Generic", "System.Linq", "MyApp.Models", "System.Math"} { + if !specs[want] { + t.Errorf("missing import %q", want) + } + } +} + +func TestStopsAtClassDeclaration(t *testing.T) { + ext := New() + facts, err := ext.Extract("Foo.cs", []byte(`namespace MyApp; + +using System.Linq; + +public class Foo { + // using System.IO; -- should NOT be parsed +} +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 2 { + t.Fatalf("expected 2 facts (1 import + 1 same_namespace, stops at class), got %d", len(facts)) + } +} + +func TestStopsAtStruct(t *testing.T) { + ext := New() + facts, err := ext.Extract("Point.cs", []byte(`namespace MyApp; + +using System; + +public struct Point { +} +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 2 { + t.Fatalf("expected 2 facts (1 import + 1 same_namespace), got %d", len(facts)) + } +} + +func TestStopsAtInterface(t *testing.T) { + ext := New() + facts, err := ext.Extract("IHandler.cs", []byte(`namespace MyApp; + +using System; + +public interface IHandler { +} +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 2 { + t.Fatalf("expected 2 facts (1 import + 1 same_namespace), got %d", len(facts)) + } +} + +func TestStopsAtRecord(t *testing.T) { + ext := New() + facts, err := ext.Extract("Person.cs", []byte(`namespace MyApp; + +using System; + +public record Person(string Name, int Age); +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 2 { + t.Fatalf("expected 2 facts (1 import + 1 same_namespace), got %d", len(facts)) + } +} + +func TestSkipsComments(t *testing.T) { + ext := New() + facts, err := ext.Extract("Foo.cs", []byte(`namespace MyApp; + +// This is a comment +/* Block comment */ +using System.Linq; + +/** + * XML doc comment + * using System.IO; + */ +using System.Text; + +public class Foo {} +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 3 { + t.Fatalf("expected 3 facts (2 imports + 1 same_namespace), got %d", len(facts)) + } +} + +func TestEmptyFile(t *testing.T) { + ext := New() + facts, err := ext.Extract("Empty.cs", []byte(`namespace MyApp; + +public class Empty {} +`)) + if err != nil { + t.Fatal(err) + } + // 0 imports + 1 same_namespace = 1 fact + if len(facts) != 1 { + t.Errorf("expected 1 fact (same_namespace only), got %d", len(facts)) + } + if len(facts) > 0 && facts[0].ImportType != "same_namespace" { + t.Errorf("expected same_namespace fact, got %q", facts[0].ImportType) + } +} + +func TestNamespaceEmission(t *testing.T) { + ext := New() + facts, err := ext.Extract("Foo.cs", []byte(`namespace MyApp.Services.Auth; + +public class AuthService {} +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 1 { + t.Fatalf("expected 1 fact (same_namespace), got %d", len(facts)) + } + if facts[0].Specifier != "MyApp.Services.Auth.*" { + t.Errorf("specifier = %q, want %q", facts[0].Specifier, "MyApp.Services.Auth.*") + } + if facts[0].ImportType != "same_namespace" { + t.Errorf("importType = %q, want %q", facts[0].ImportType, "same_namespace") + } +} + +func TestFileScopedNamespace(t *testing.T) { + ext := New() + facts, err := ext.Extract("Foo.cs", []byte(`namespace MyApp.Models; + +using System.ComponentModel; + +public class User {} +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 2 { + t.Fatalf("expected 2 facts, got %d", len(facts)) + } + // same_namespace fact should use the file-scoped namespace. + if facts[1].Specifier != "MyApp.Models.*" { + t.Errorf("same_namespace specifier = %q, want %q", facts[1].Specifier, "MyApp.Models.*") + } +} + +func TestBlockNamespace(t *testing.T) { + ext := New() + facts, err := ext.Extract("Foo.cs", []byte(`namespace MyApp.Models +{ + using System.ComponentModel; + + public class User {} +} +`)) + if err != nil { + t.Fatal(err) + } + if len(facts) != 2 { + t.Fatalf("expected 2 facts, got %d", len(facts)) + } + if facts[1].Specifier != "MyApp.Models.*" { + t.Errorf("same_namespace specifier = %q, want %q", facts[1].Specifier, "MyApp.Models.*") + } +} + +func TestNoNamespace(t *testing.T) { + ext := New() + facts, err := ext.Extract("Program.cs", []byte(`using System; + +Console.WriteLine("Hello"); +`)) + if err != nil { + t.Fatal(err) + } + // 1 import, no namespace = no same_namespace fact + if len(facts) != 1 { + t.Fatalf("expected 1 fact, got %d", len(facts)) + } + if facts[0].ImportType != "absolute" { + t.Errorf("importType = %q, want %q", facts[0].ImportType, "absolute") + } +} + +func TestExtensions(t *testing.T) { + ext := New() + exts := ext.Extensions() + if len(exts) != 1 || exts[0] != ".cs" { + t.Errorf("Extensions() = %v, want [\".cs\"]", exts) + } +} + +func TestLanguage(t *testing.T) { + ext := New() + if ext.Language() != "csharp" { + t.Errorf("Language() = %q, want %q", ext.Language(), "csharp") + } +} + +func TestExtractDefinitions(t *testing.T) { + ext := New() + + cases := []struct { + name string + content string + symbols []string + expected []string + }{ + { + name: "class", + content: "public class UserService : IUserService {", + symbols: []string{"UserService"}, + expected: []string{"public class UserService : IUserService"}, + }, + { + name: "interface", + content: "public interface IHandler {", + symbols: []string{"IHandler"}, + expected: []string{"public interface IHandler"}, + }, + { + name: "struct", + content: "public struct Point {", + symbols: []string{"Point"}, + expected: []string{"public struct Point"}, + }, + { + name: "enum", + content: "public enum Status { Active, Inactive }", + symbols: []string{"Status"}, + expected: []string{"public enum Status"}, + }, + { + name: "record", + content: "public record Person(string Name, int Age) {", + symbols: []string{"Person"}, + expected: []string{"public record Person(string Name, int Age)"}, + }, + { + name: "method", + content: "public async Task GetUserAsync(int id) {", + symbols: []string{"GetUserAsync"}, + expected: []string{"public async Task GetUserAsync(int id)"}, + }, + { + name: "property", + content: "public string Name { get; set; }", + symbols: []string{"Name"}, + expected: []string{"public string Name"}, + }, + { + name: "delegate", + content: "public delegate void EventHandler(object sender, EventArgs e);", + symbols: []string{"EventHandler"}, + expected: []string{"public delegate void EventHandler(object sender, EventArgs e);"}, + }, + { + name: "const", + content: `public const string Version = "1.0";`, + symbols: []string{"Version"}, + expected: []string{`public const string Version = "1.0";`}, + }, + { + name: "readonly", + content: "public static readonly int MaxRetries = 3;", + symbols: []string{"MaxRetries"}, + expected: []string{"public static readonly int MaxRetries = 3;"}, + }, + { + name: "no match", + content: "public class Foo {}", + symbols: []string{"nope"}, + expected: nil, + }, + { + name: "empty symbols", + content: "public class Foo {}", + symbols: nil, + expected: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ext.ExtractDefinitions([]byte(tc.content), tc.symbols) + if tc.expected == nil { + if len(got) != 0 { + t.Errorf("expected nil/empty, got %v", got) + } + return + } + assertNames(t, got, tc.expected) + }) + } +} + +func TestIsStdlib(t *testing.T) { + cases := []struct { + spec string + want bool + }{ + {"System.Collections.Generic", true}, + {"System.Linq", true}, + {"System", true}, + {"Microsoft.Extensions.DependencyInjection", true}, + {"Microsoft", true}, + {"Windows.UI.Xaml", true}, + {"MyApp.Models.User", false}, + {"Newtonsoft.Json", false}, + {"NUnit.Framework", false}, + } + for _, tc := range cases { + if got := IsStdlib(tc.spec); got != tc.want { + t.Errorf("IsStdlib(%q) = %v, want %v", tc.spec, got, tc.want) + } + } +} + +func assertNames(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Errorf("length = %d, want %d (%v vs %v)", len(got), len(want), got, want) + return + } + for i := range got { + if got[i] != want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/extractor/csharp/stdlib.go b/internal/extractor/csharp/stdlib.go new file mode 100644 index 0000000..644f3b1 --- /dev/null +++ b/internal/extractor/csharp/stdlib.go @@ -0,0 +1,20 @@ +package csharp + +// stdlibPrefixes lists top-level C# standard library and framework namespace prefixes. +// Imports starting with these prefixes are classified as external/stdlib. +var stdlibPrefixes = []string{ + "System.", + "Microsoft.", + "Windows.", +} + +// IsStdlib returns true if the import specifier refers to a C# stdlib/framework namespace. +func IsStdlib(spec string) bool { + for _, prefix := range stdlibPrefixes { + if len(spec) >= len(prefix) && spec[:len(prefix)] == prefix { + return true + } + } + // Exact matches (e.g., "System" without a dot suffix). + return spec == "System" || spec == "Microsoft" || spec == "Windows" +} diff --git a/internal/grader/grader.go b/internal/grader/grader.go index 1ea0e6d..24eb948 100644 --- a/internal/grader/grader.go +++ b/internal/grader/grader.go @@ -45,12 +45,16 @@ func gradeMustRead(out *model.AnalysisOutput, fg *FileGrade) float64 { // Symbol coverage: check what fraction of entries have symbols. // Entries with direction "same_package" are excluded from the symbol rate // because same-package files have no import edges to derive symbols from. + // C# files (.cs) are also excluded because C# uses namespace-level imports + // (using directives), so individual type symbols can't be derived from the + // import statement — the resolver maps namespaces to representative files. + isCSharp := strings.HasSuffix(out.Subject, ".cs") if len(out.MustRead) > 0 { withSymbols := 0 eligibleForSymbols := 0 withDirection := 0 for _, e := range out.MustRead { - if e.Direction != "" && e.Direction != "same_package" { + if e.Direction != "" && e.Direction != "same_package" && !isCSharp { eligibleForSymbols++ if len(e.Symbols) > 0 { withSymbols++ diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 3723cef..94034bb 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -24,6 +24,7 @@ import ( pyextractor "github.com/kehoej/contextception/internal/extractor/python" rustextractor "github.com/kehoej/contextception/internal/extractor/rust" tsextractor "github.com/kehoej/contextception/internal/extractor/typescript" + csharpextractor "github.com/kehoej/contextception/internal/extractor/csharp" gitpkg "github.com/kehoej/contextception/internal/git" "github.com/kehoej/contextception/internal/resolver" goresolver "github.com/kehoej/contextception/internal/resolver/golang" @@ -31,6 +32,7 @@ import ( pyresolver "github.com/kehoej/contextception/internal/resolver/python" rustresolver "github.com/kehoej/contextception/internal/resolver/rust" tsresolver "github.com/kehoej/contextception/internal/resolver/typescript" + csharpresolver "github.com/kehoej/contextception/internal/resolver/csharp" ) // Config configures the indexer. @@ -71,6 +73,7 @@ func NewIndexer(cfg Config) (*Indexer, error) { goExt := goextractor.New() javaExt := javaextractor.New() rustExt := rustextractor.New() + csharpExt := csharpextractor.New() pyRes := pyresolver.New(cfg.RepoRoot) roots, err := pyRes.DetectPackageRoots() @@ -94,12 +97,16 @@ func NewIndexer(cfg Config) (*Indexer, error) { for _, ext := range rustExt.Extensions() { extractors[ext] = rustExt } + for _, ext := range csharpExt.Extensions() { + extractors[ext] = csharpExt + } tsRes := tsresolver.New(cfg.RepoRoot) tsRes.DetectWorkspaces() goRes := goresolver.New(cfg.RepoRoot) javaRes := javaresolver.New(cfg.RepoRoot) rustRes := rustresolver.New(cfg.RepoRoot) + csharpRes := csharpresolver.New(cfg.RepoRoot) resolvers := map[string]resolver.Resolver{ "python": pyRes, @@ -108,6 +115,7 @@ func NewIndexer(cfg Config) (*Indexer, error) { "go": goRes, "java": javaRes, "rust": rustRes, + "csharp": csharpRes, } return &Indexer{ diff --git a/internal/indexer/scanner.go b/internal/indexer/scanner.go index 57e4eb1..5d3616e 100644 --- a/internal/indexer/scanner.go +++ b/internal/indexer/scanner.go @@ -84,6 +84,7 @@ func NewScanner(repoRoot string, configIgnore []string) *Scanner { ".go": "go", ".java": "java", ".rs": "rust", + ".cs": "csharp", }, ignoreDirs: ignoredDirs, configIgnorePrefixes: configIgnore, diff --git a/internal/resolver/csharp/csharp.go b/internal/resolver/csharp/csharp.go new file mode 100644 index 0000000..e47845f --- /dev/null +++ b/internal/resolver/csharp/csharp.go @@ -0,0 +1,585 @@ +// Package csharp implements import resolution for C# source files. +package csharp + +import ( + "hash/fnv" + "os" + "path/filepath" + "strings" + + "github.com/kehoej/contextception/internal/classify" + csharppkg "github.com/kehoej/contextception/internal/extractor/csharp" + "github.com/kehoej/contextception/internal/model" +) + +// Resolver resolves C# import paths to repository files. +type Resolver struct { + repoRoot string + sourceRoots []string // repo-relative directories containing .csproj files (or ".") + + // projectNamespaceMap maps dotted project directory names to their repo-relative paths. + // e.g., "MediaBrowser.Controller" → "MediaBrowser.Controller" + // "Jellyfin.Database.Implementations" → "src/Jellyfin.Database/Jellyfin.Database.Implementations" + // This handles the C# convention where project directories use dots in their names. + projectNamespaceMap map[string]string +} + +// New creates a new C# resolver for the given repository root. +// It auto-detects source roots from .csproj project files. +func New(repoRoot string) *Resolver { + r := &Resolver{ + repoRoot: repoRoot, + projectNamespaceMap: make(map[string]string), + } + r.detectSourceRoots() + return r +} + +// detectSourceRoots finds C# project directories by scanning for .csproj files. +// Each directory containing a .csproj is treated as a source root since C# source +// files live alongside their project files (no src/main/java convention). +func (r *Resolver) detectSourceRoots() { + r.walkForProjects(r.repoRoot, "", 0, 5) + + // Also check simple layouts. + for _, c := range []string{"src", "."} { + abs := filepath.Join(r.repoRoot, c) + if info, err := os.Stat(abs); err == nil && info.IsDir() { + r.sourceRoots = append(r.sourceRoots, c) + } + } + + // Deduplicate. + seen := make(map[string]bool) + unique := r.sourceRoots[:0] + for _, root := range r.sourceRoots { + if !seen[root] { + seen[root] = true + unique = append(unique, root) + } + } + r.sourceRoots = unique + + // If nothing found, use root. + if len(r.sourceRoots) == 0 { + r.sourceRoots = []string{"."} + } + + // Also register directory basenames as project namespace keys (handles the common + // case where the directory name IS the project name, e.g., "MediaBrowser.Controller/"). + for _, root := range r.sourceRoots { + if root == "." || root == "src" { + continue + } + dirName := filepath.Base(root) + if _, exists := r.projectNamespaceMap[dirName]; !exists { + r.projectNamespaceMap[dirName] = root + } + } +} + +// walkForProjects recursively walks directories looking for .csproj files. +func (r *Resolver) walkForProjects(absDir, relDir string, depth, maxDepth int) { + if depth >= maxDepth { + return + } + + entries, err := os.ReadDir(absDir) + if err != nil { + return + } + + var csprojNames []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + for _, ext := range []string{".csproj", ".shproj"} { + if strings.HasSuffix(name, ext) { + csprojNames = append(csprojNames, strings.TrimSuffix(name, ext)) + } + } + } + + if len(csprojNames) > 0 { + root := relDir + if root == "" { + root = "." + } + r.sourceRoots = append(r.sourceRoots, root) + // Register the csproj name(s) as project namespace keys. + // This handles cases where the csproj name differs from the directory name + // (e.g., src/Compilers/Core/Portable/Microsoft.CodeAnalysis.csproj). + for _, name := range csprojNames { + r.projectNamespaceMap[name] = root + } + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + // Skip hidden dirs and common non-source dirs. + if strings.HasPrefix(name, ".") || name == "bin" || name == "obj" || name == "packages" || name == "TestResults" { + continue + } + + childAbs := filepath.Join(absDir, name) + var childRel string + if relDir == "" { + childRel = name + } else { + childRel = relDir + "/" + name + } + + r.walkForProjects(childAbs, childRel, depth+1, maxDepth) + } +} + +// Resolve maps a C# using directive to a repository file. +func (r *Resolver) Resolve(srcFile string, fact model.ImportFact, repoRoot string) (model.ResolveResult, error) { + spec := fact.Specifier + + // Same-namespace imports are handled by ResolveAll. + if fact.ImportType == "same_namespace" { + return model.ResolveResult{ + External: true, + ResolutionMethod: "external", + Reason: "same_namespace_handled_by_resolve_all", + }, nil + } + + // Stdlib check — but skip it if the namespace matches a local project or + // can be found as a subdirectory within a source root. This handles repos + // like Roslyn (Microsoft.CodeAnalysis.*) and EF Core (Microsoft.EntityFrameworkCore.*) + // where the namespace uses a stdlib prefix but the code is in-repo. + if csharppkg.IsStdlib(spec) && !r.matchesLocalProject(spec) && !r.canResolveLocally(spec) { + return model.ResolveResult{ + External: true, + ResolutionMethod: "external", + Reason: "stdlib", + }, nil + } + + // Strip wildcard for path resolution. + resolveSpec := spec + if strings.HasSuffix(resolveSpec, ".*") { + resolveSpec = strings.TrimSuffix(resolveSpec, ".*") + } + + // Strategy 1: Project namespace mapping. + // C# projects use dotted directory names (e.g., "MediaBrowser.Controller/"). + // A using like "MediaBrowser.Controller.Entities" means: + // - The project root is "MediaBrowser.Controller" + // - The sub-path within the project is "Entities" + // We try matching the longest project namespace prefix first. + if result, found := r.resolveViaProjectNamespace(resolveSpec, srcFile); found { + return result, nil + } + + // Strategy 2: Convert namespace to path directly: Foo.Bar.Baz → Foo/Bar/Baz.cs + // Works for simple projects where namespace components match directory segments. + filePath := strings.ReplaceAll(resolveSpec, ".", "/") + ".cs" + for _, root := range r.sourceRoots { + candidate := r.joinRoot(root, filePath) + abs := filepath.Join(r.repoRoot, candidate) + if _, err := os.Stat(abs); err == nil { + return model.ResolveResult{ + ResolvedPath: candidate, + ResolutionMethod: "namespace_to_file", + }, nil + } + } + + // Strategy 3: Try namespace suffix as subdirectory within each source root. + // Handles cases where the project name differs from the namespace + // (e.g., project "Avalonia.Base" contains namespace "Avalonia.Media" at Media/). + parts := strings.Split(resolveSpec, ".") + for i := 1; i < len(parts); i++ { + suffix := strings.Join(parts[i:], "/") + for _, root := range r.sourceRoots { + if root == "." || root == "src" { + continue + } + dirPath := filepath.ToSlash(filepath.Join(root, suffix)) + absDirPath := filepath.Join(r.repoRoot, dirPath) + if info, err := os.Stat(absDirPath); err == nil && info.IsDir() { + csFiles := collectCSFiles(absDirPath, dirPath) + if len(csFiles) > 0 { + p := pickRepresentativeFile(csFiles, srcFile) + return model.ResolveResult{ + ResolvedPath: p, + ResolutionMethod: "namespace_subdir", + }, nil + } + } + // Also try as a file: e.g., Avalonia.Threading → root/Threading.cs + fileSuffix := suffix + ".cs" + filePath := filepath.ToSlash(filepath.Join(root, fileSuffix)) + abs := filepath.Join(r.repoRoot, filePath) + if _, err := os.Stat(abs); err == nil { + return model.ResolveResult{ + ResolvedPath: filePath, + ResolutionMethod: "namespace_subdir", + }, nil + } + } + } + + // Strategy 4: Try last component as filename (C# namespaces don't map 1:1 to paths). + // e.g., MyApp.Services.UserService → try UserService.cs in all source roots. + if len(parts) >= 2 { + lastComponent := parts[len(parts)-1] + ".cs" + for _, root := range r.sourceRoots { + if result, found := r.findFileRecursive(root, lastComponent); found { + return result, nil + } + } + } + + // Not found locally → external (NuGet package). + return model.ResolveResult{ + External: true, + ResolutionMethod: "external", + Reason: "third_party", + }, nil +} + +// resolveViaProjectNamespace tries to resolve a namespace specifier by matching +// its prefix against known project directory names (dotted names like "MediaBrowser.Controller"). +// +// For example, with source root "MediaBrowser.Controller" and spec "MediaBrowser.Controller.Entities": +// - Strips prefix → remainder is "Entities" +// - Looks for "MediaBrowser.Controller/Entities.cs" (file) or +// "MediaBrowser.Controller/Entities/" (directory with .cs files) +func (r *Resolver) resolveViaProjectNamespace(spec, srcFile string) (model.ResolveResult, bool) { + // Try longest prefix match first (more specific project names first). + // Sort by length descending for correctness. + var bestMatch string + var bestRoot string + for projName, projRoot := range r.projectNamespaceMap { + if !strings.HasPrefix(spec, projName) { + continue + } + // Must be exact prefix: either spec == projName, or next char is '.'. + if len(spec) > len(projName) && spec[len(projName)] != '.' { + continue + } + if len(projName) > len(bestMatch) { + bestMatch = projName + bestRoot = projRoot + } + } + + if bestMatch == "" { + return model.ResolveResult{}, false + } + + // Strip the matched prefix to get the remainder. + var remainder string + if len(spec) > len(bestMatch) { + remainder = spec[len(bestMatch)+1:] // skip the dot + } + + // Determine the target path within the project. + var targetDir string + if remainder == "" { + // The spec exactly matches the project namespace (e.g., "Orleans.Runtime"). + // The target is the project root directory itself. + targetDir = bestRoot + } else { + // Convert remainder to subpath: "Entities.Genre" → "Entities/Genre" + subPath := strings.ReplaceAll(remainder, ".", "/") + + // Try as a file: project_root/sub/path.cs + filePath := subPath + ".cs" + candidate := filepath.ToSlash(filepath.Join(bestRoot, filePath)) + abs := filepath.Join(r.repoRoot, candidate) + if _, err := os.Stat(abs); err == nil { + return model.ResolveResult{ + ResolvedPath: candidate, + ResolutionMethod: "project_namespace", + }, true + } + + targetDir = filepath.ToSlash(filepath.Join(bestRoot, subPath)) + } + + // Try as a directory: the using imports a namespace, not a file. + // e.g., "using MediaBrowser.Controller.Entities;" → MediaBrowser.Controller/Entities/ + // Pick a representative .cs file using context-aware selection. + dirPath := targetDir + absDirPath := filepath.Join(r.repoRoot, dirPath) + if info, err := os.Stat(absDirPath); err == nil && info.IsDir() { + csFiles := collectCSFiles(absDirPath, dirPath) + if len(csFiles) > 0 { + p := pickRepresentativeFile(csFiles, srcFile) + return model.ResolveResult{ + ResolvedPath: p, + ResolutionMethod: "project_namespace", + }, true + } + // No .cs files at this level — try first subdirectory that has .cs files. + entries, err := os.ReadDir(absDirPath) + if err == nil { + for _, e := range entries { + if !e.IsDir() { + continue + } + subDirAbs := filepath.Join(absDirPath, e.Name()) + subDirRel := filepath.ToSlash(filepath.Join(dirPath, e.Name())) + csFiles := collectCSFiles(subDirAbs, subDirRel) + if len(csFiles) > 0 { + p := pickRepresentativeFile(csFiles, srcFile) + return model.ResolveResult{ + ResolvedPath: p, + ResolutionMethod: "project_namespace", + }, true + } + } + } + } + + return model.ResolveResult{}, false +} + +// ResolveAll implements MultiResolver for C#. Handles same_namespace imports +// by resolving to sibling .cs files in the same directory. Also expands +// namespace-level using directives to all .cs files in the target directory. +func (r *Resolver) ResolveAll(srcFile string, fact model.ImportFact, repoRoot string) ([]model.ResolveResult, error) { + if fact.ImportType == "same_namespace" { + return r.resolveSameNamespace(srcFile) + } + + // For absolute/alias/static using directives, delegate to single Resolve. + // Namespace-directory expansion is NOT done here — the same_namespace synthetic + // fact handles sibling discovery (like Java's same_package). This prevents + // namespace-level using directives from flooding must_read with all files + // in the target directory. + result, err := r.Resolve(srcFile, fact, repoRoot) + if err != nil { + return nil, err + } + return []model.ResolveResult{result}, nil +} + +// resolveSameNamespace resolves same_namespace facts to sibling .cs files. +func (r *Resolver) resolveSameNamespace(srcFile string) ([]model.ResolveResult, error) { + srcDir := filepath.Dir(srcFile) + absDir := filepath.Join(r.repoRoot, srcDir) + entries, err := os.ReadDir(absDir) + if err != nil { + return []model.ResolveResult{{ + External: true, + ResolutionMethod: "same_namespace", + Reason: "not_found", + }}, nil + } + + srcBase := filepath.Base(srcFile) + var results []model.ResolveResult + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".cs") || name == srcBase { + continue + } + // Skip test files as siblings. + siblingPath := filepath.ToSlash(filepath.Join(srcDir, name)) + if classify.IsTestFile(siblingPath) { + continue + } + p := filepath.Join(srcDir, name) + p = filepath.ToSlash(p) + results = append(results, model.ResolveResult{ + ResolvedPath: p, + ResolutionMethod: "same_namespace", + }) + } + + if len(results) == 0 { + return []model.ResolveResult{{ + External: true, + ResolutionMethod: "same_namespace", + Reason: "no_siblings", + }}, nil + } + + // Cap to prevent explosion in large namespaces. + const maxSameNamespace = 10 + if len(results) > maxSameNamespace { + results = results[:maxSameNamespace] + } + + return results, nil +} + +// pickRepresentativeFile selects a .cs file from a directory to represent a +// namespace-level import edge. Instead of always picking the alphabetically first +// file (which creates artificial hub nodes), this uses two strategies: +// 1. Prefix match: prefer files whose name matches the source file's stem +// (e.g., SessionManager.cs importing Controller.Session → pick ISessionManager.cs) +// 2. Hash-based rotation: if no prefix match, hash the source file path to pick +// a deterministic but distributed file from the directory. +// +// This prevents any single file from accumulating all namespace-level edges. +func pickRepresentativeFile(csFiles []string, srcFile string) string { + if len(csFiles) == 0 { + return "" + } + + // Strategy 1: Find a file whose stem matches or is prefixed by the source stem. + srcStem := strings.TrimSuffix(filepath.Base(srcFile), ".cs") + if srcStem != "" { + // Try exact stem match first (e.g., SessionManager → ISessionManager.cs or SessionManager.cs) + for _, f := range csFiles { + fStem := strings.TrimSuffix(filepath.Base(f), ".cs") + // Match: same stem, or I+stem (interface), or stem without I prefix + if strings.EqualFold(fStem, srcStem) || + strings.EqualFold(fStem, "I"+srcStem) || + (len(srcStem) > 1 && srcStem[0] == 'I' && strings.EqualFold(fStem, srcStem[1:])) { + return f + } + } + // Try prefix match (e.g., BaseItem → BaseItemExtensions.cs) + for _, f := range csFiles { + fStem := strings.TrimSuffix(filepath.Base(f), ".cs") + if len(fStem) > len(srcStem) && strings.HasPrefix(fStem, srcStem) { + return f + } + } + } + + // Strategy 2: Prefer interface files (I*.cs) as representatives — they're + // more stable and informative than implementations. + var interfaces []string + for _, f := range csFiles { + fBase := filepath.Base(f) + if len(fBase) > 2 && fBase[0] == 'I' && fBase[1] >= 'A' && fBase[1] <= 'Z' { + interfaces = append(interfaces, f) + } + } + + // Strategy 3: Hash-based rotation to distribute edges. + pool := csFiles + if len(interfaces) > 0 { + pool = interfaces + } + h := fnv.New32a() + h.Write([]byte(srcFile)) + idx := int(h.Sum32()) % len(pool) + if idx < 0 { + idx = -idx + } + return pool[idx] +} + +// collectCSFiles returns all .cs filenames (repo-relative) in a directory. +func collectCSFiles(absDir, relDir string) []string { + entries, err := os.ReadDir(absDir) + if err != nil { + return nil + } + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".cs") { + files = append(files, filepath.ToSlash(filepath.Join(relDir, e.Name()))) + } + } + return files +} + +// canResolveLocally does a quick check if the namespace suffix exists as a subdirectory +// within any source root. This is cheaper than full resolution and used to skip stdlib +// classification for in-repo namespaces whose project name doesn't match the namespace. +func (r *Resolver) canResolveLocally(spec string) bool { + parts := strings.Split(spec, ".") + for i := 1; i < len(parts); i++ { + suffix := strings.Join(parts[i:], "/") + for _, root := range r.sourceRoots { + if root == "." || root == "src" { + continue + } + dirPath := filepath.Join(r.repoRoot, root, suffix) + if info, err := os.Stat(dirPath); err == nil && info.IsDir() { + return true + } + // Also try as a file. + filePath := dirPath + ".cs" + if _, err := os.Stat(filePath); err == nil { + return true + } + } + } + return false +} + +// matchesLocalProject returns true if the namespace spec could resolve to a local project. +// This is used to override stdlib classification for repos that contain Microsoft.* or System.* +// projects (e.g., Roslyn contains Microsoft.CodeAnalysis.*). +func (r *Resolver) matchesLocalProject(spec string) bool { + for projName := range r.projectNamespaceMap { + if strings.HasPrefix(spec, projName) { + if len(spec) == len(projName) || spec[len(projName)] == '.' { + return true + } + } + } + return false +} + +// findFileRecursive searches a source root for a file by name (non-recursive walk limited to depth 5). +func (r *Resolver) findFileRecursive(root, fileName string) (model.ResolveResult, bool) { + absRoot := filepath.Join(r.repoRoot, root) + if root == "." { + absRoot = r.repoRoot + } + + var found string + _ = filepath.WalkDir(absRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + name := d.Name() + if strings.HasPrefix(name, ".") || name == "bin" || name == "obj" || name == "node_modules" || name == "packages" { + return filepath.SkipDir + } + // Limit depth. + rel, _ := filepath.Rel(absRoot, path) + if strings.Count(filepath.ToSlash(rel), "/") >= 5 { + return filepath.SkipDir + } + return nil + } + if d.Name() == fileName { + rel, _ := filepath.Rel(r.repoRoot, path) + found = filepath.ToSlash(rel) + return filepath.SkipAll + } + return nil + }) + + if found != "" { + return model.ResolveResult{ + ResolvedPath: found, + ResolutionMethod: "filename_search", + }, true + } + return model.ResolveResult{}, false +} + +// joinRoot joins a source root with a file path, handling the "." root case. +func (r *Resolver) joinRoot(root, filePath string) string { + var candidate string + if root == "." { + candidate = filePath + } else { + candidate = filepath.Join(root, filePath) + } + return filepath.ToSlash(candidate) +} diff --git a/internal/resolver/csharp/csharp_test.go b/internal/resolver/csharp/csharp_test.go new file mode 100644 index 0000000..5f6ff5e --- /dev/null +++ b/internal/resolver/csharp/csharp_test.go @@ -0,0 +1,335 @@ +package csharp + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kehoej/contextception/internal/model" +) + +func TestResolveStdlib(t *testing.T) { + dir := t.TempDir() + r := New(dir) + + result, err := r.Resolve("Foo.cs", model.ImportFact{ + Specifier: "System.Collections.Generic", + }, dir) + if err != nil { + t.Fatal(err) + } + if !result.External { + t.Error("expected external") + } + if result.Reason != "stdlib" { + t.Errorf("reason = %q, want %q", result.Reason, "stdlib") + } +} + +func TestResolveLocalFile(t *testing.T) { + dir := t.TempDir() + + // Create a C# project layout with .csproj. + projDir := filepath.Join(dir, "MyApp") + modelsDir := filepath.Join(projDir, "Models") + if err := os.MkdirAll(modelsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projDir, "MyApp.csproj"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(modelsDir, "User.cs"), []byte("public class User {}"), 0o644); err != nil { + t.Fatal(err) + } + + r := New(dir) + + result, err := r.Resolve("MyApp/Services/UserService.cs", model.ImportFact{ + Specifier: "Models.User", + }, dir) + if err != nil { + t.Fatal(err) + } + if result.External { + t.Error("expected local resolution") + } + if result.ResolvedPath != "MyApp/Models/User.cs" { + t.Errorf("resolvedPath = %q", result.ResolvedPath) + } +} + +func TestResolveThirdParty(t *testing.T) { + dir := t.TempDir() + r := New(dir) + + result, err := r.Resolve("Foo.cs", model.ImportFact{ + Specifier: "Newtonsoft.Json", + }, dir) + if err != nil { + t.Fatal(err) + } + if !result.External { + t.Error("expected external") + } + if result.Reason != "third_party" { + t.Errorf("reason = %q, want %q", result.Reason, "third_party") + } +} + +func TestResolveCsprojDetection(t *testing.T) { + dir := t.TempDir() + + // Create solution with two projects. + for _, proj := range []string{"MyApp.Core", "MyApp.Web"} { + projDir := filepath.Join(dir, proj) + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projDir, proj+".csproj"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + } + + r := New(dir) + + // Should detect both project directories as source roots. + found := make(map[string]bool) + for _, root := range r.sourceRoots { + found[root] = true + } + if !found["MyApp.Core"] { + t.Error("expected MyApp.Core as source root") + } + if !found["MyApp.Web"] { + t.Error("expected MyApp.Web as source root") + } +} + +func TestResolveByFilenameSearch(t *testing.T) { + dir := t.TempDir() + + // Create a file in a nested directory that doesn't match namespace-to-path mapping. + projDir := filepath.Join(dir, "MyApp") + nestedDir := filepath.Join(projDir, "Infrastructure", "Data") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projDir, "MyApp.csproj"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nestedDir, "UserRepository.cs"), []byte("public class UserRepository {}"), 0o644); err != nil { + t.Fatal(err) + } + + r := New(dir) + + // Namespace doesn't map to path: MyApp.Data.UserRepository → try filename search. + result, err := r.Resolve("MyApp/Services/App.cs", model.ImportFact{ + Specifier: "MyApp.Data.UserRepository", + }, dir) + if err != nil { + t.Fatal(err) + } + if result.External { + t.Error("expected local resolution via filename search") + } + if result.ResolvedPath != "MyApp/Infrastructure/Data/UserRepository.cs" { + t.Errorf("resolvedPath = %q", result.ResolvedPath) + } +} + +func TestResolveAllSameNamespace(t *testing.T) { + dir := t.TempDir() + + // Create source files in same directory. + srcDir := filepath.Join(dir, "MyApp", "Models") + if err := os.MkdirAll(srcDir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"User.cs", "Product.cs", "Order.cs"} { + if err := os.WriteFile(filepath.Join(srcDir, name), []byte("public class "+strings.TrimSuffix(name, ".cs")+" {}"), 0o644); err != nil { + t.Fatal(err) + } + } + + r := New(dir) + + results, err := r.ResolveAll("MyApp/Models/User.cs", model.ImportFact{ + Specifier: "MyApp.Models.*", + ImportType: "same_namespace", + }, dir) + if err != nil { + t.Fatal(err) + } + + // Should find Product.cs and Order.cs (not User.cs itself). + if len(results) != 2 { + t.Fatalf("expected 2 same-namespace results, got %d", len(results)) + } + for _, result := range results { + if result.ResolutionMethod != "same_namespace" { + t.Errorf("method = %q, want %q", result.ResolutionMethod, "same_namespace") + } + } +} + +func TestResolveAllSkipsTestFiles(t *testing.T) { + dir := t.TempDir() + + srcDir := filepath.Join(dir, "MyApp", "Services") + if err := os.MkdirAll(srcDir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"UserService.cs", "UserServiceTest.cs", "UserServiceTests.cs", "OrderService.cs"} { + if err := os.WriteFile(filepath.Join(srcDir, name), []byte("class "+strings.TrimSuffix(name, ".cs")+" {}"), 0o644); err != nil { + t.Fatal(err) + } + } + + r := New(dir) + + results, err := r.ResolveAll("MyApp/Services/UserService.cs", model.ImportFact{ + Specifier: "MyApp.Services.*", + ImportType: "same_namespace", + }, dir) + if err != nil { + t.Fatal(err) + } + + // Should only find OrderService.cs (skip test files and self). + if len(results) != 1 { + t.Fatalf("expected 1 same-namespace result, got %d", len(results)) + } + if results[0].ResolvedPath != "MyApp/Services/OrderService.cs" { + t.Errorf("resolvedPath = %q, want %q", results[0].ResolvedPath, "MyApp/Services/OrderService.cs") + } +} + +func TestResolveProjectNamespace(t *testing.T) { + dir := t.TempDir() + + // Create a C# project with dotted directory name (like Jellyfin). + projDir := filepath.Join(dir, "MediaBrowser.Controller") + entitiesDir := filepath.Join(projDir, "Entities") + if err := os.MkdirAll(entitiesDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projDir, "MediaBrowser.Controller.csproj"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(entitiesDir, "Genre.cs"), []byte("public class Genre {}"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(entitiesDir, "BaseItem.cs"), []byte("public class BaseItem {}"), 0o644); err != nil { + t.Fatal(err) + } + + r := New(dir) + + // using MediaBrowser.Controller.Entities should resolve to a file in the directory. + result, err := r.Resolve("SomeApp/Foo.cs", model.ImportFact{ + Specifier: "MediaBrowser.Controller.Entities", + ImportType: "absolute", + }, dir) + if err != nil { + t.Fatal(err) + } + if result.External { + t.Error("expected local resolution via project namespace") + } + if result.ResolutionMethod != "project_namespace" { + t.Errorf("method = %q, want %q", result.ResolutionMethod, "project_namespace") + } +} + +func TestResolveAllAbsoluteNamespace(t *testing.T) { + dir := t.TempDir() + + // Create dotted project directory with multiple files. + projDir := filepath.Join(dir, "MediaBrowser.Controller") + entitiesDir := filepath.Join(projDir, "Entities") + if err := os.MkdirAll(entitiesDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projDir, "MediaBrowser.Controller.csproj"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + for _, name := range []string{"Genre.cs", "BaseItem.cs", "Folder.cs"} { + if err := os.WriteFile(filepath.Join(entitiesDir, name), []byte("public class "+strings.TrimSuffix(name, ".cs")+" {}"), 0o644); err != nil { + t.Fatal(err) + } + } + + r := New(dir) + + // ResolveAll for absolute using should NOT expand to all files (same_namespace handles that). + // It should delegate to single Resolve, returning 1 result. + results, err := r.ResolveAll("SomeApp/Foo.cs", model.ImportFact{ + Specifier: "MediaBrowser.Controller.Entities", + ImportType: "absolute", + }, dir) + if err != nil { + t.Fatal(err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 result (single Resolve), got %d: %+v", len(results), results) + } + if results[0].External { + t.Error("expected local resolution") + } +} + +func TestResolveProjectNamespaceFile(t *testing.T) { + dir := t.TempDir() + + // Create dotted project directory with a specific file. + projDir := filepath.Join(dir, "Jellyfin.Api") + helpersDir := filepath.Join(projDir, "Helpers") + if err := os.MkdirAll(helpersDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projDir, "Jellyfin.Api.csproj"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(helpersDir, "RequestHelpers.cs"), []byte("public static class RequestHelpers {}"), 0o644); err != nil { + t.Fatal(err) + } + + r := New(dir) + + // using Jellyfin.Api.Helpers.RequestHelpers → Jellyfin.Api/Helpers/RequestHelpers.cs + result, err := r.Resolve("SomeApp/Foo.cs", model.ImportFact{ + Specifier: "Jellyfin.Api.Helpers.RequestHelpers", + ImportType: "absolute", + }, dir) + if err != nil { + t.Fatal(err) + } + if result.External { + t.Error("expected local resolution") + } + if result.ResolvedPath != "Jellyfin.Api/Helpers/RequestHelpers.cs" { + t.Errorf("resolvedPath = %q, want %q", result.ResolvedPath, "Jellyfin.Api/Helpers/RequestHelpers.cs") + } +} + +func TestResolveSameNamespaceHandledByResolveAll(t *testing.T) { + dir := t.TempDir() + r := New(dir) + + result, err := r.Resolve("Foo.cs", model.ImportFact{ + Specifier: "MyApp.*", + ImportType: "same_namespace", + }, dir) + if err != nil { + t.Fatal(err) + } + if !result.External { + t.Error("expected external (delegated to ResolveAll)") + } + if result.Reason != "same_namespace_handled_by_resolve_all" { + t.Errorf("reason = %q", result.Reason) + } +} diff --git a/scripts/compare/results.json b/scripts/compare/results.json index 251b3f9..0abc833 100644 --- a/scripts/compare/results.json +++ b/scripts/compare/results.json @@ -1869,16 +1869,654 @@ "avg_aider_4k_recall": 0.211, "avg_aider_8k_precision": 0.021 } + }, + "efcore": { + "archetype_count": 17, + "files": [ + { + "file": "src/EFCore.SqlServer/Extensions/SqlServerServiceCollectionExtensions.cs", + "archetype": "Service/Controller", + "indegree": 0, + "outdegree": 18, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 2, + "confidence": 1, + "tokens": 1317, + "total_files": 27, + "must_read_files": [ + "src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs", + "src/EFCore/Infrastructure/Internal/ILazyLoaderFactory.cs", + "src/EFCore/Metadata/Internal/IRuntimeServiceProperty.cs", + "src/EFCore/Query/Internal/IQueryCompiler.cs", + "src/EFCore/ValueGeneration/Internal/TemporaryLongValueGenerator.cs", + "src/EFCore/Diagnostics/Internal/ScopedLoggerFactory.cs", + "src/EFCore/Update/Internal/UpdateAdapterFactory.cs", + "src/EFCore.Design/Migrations/Internal/ISnapshotModelProcessor.cs", + "src/EFCore.SqlServer/Extensions/FullTextSearchResult.cs", + "src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs" + ] + }, + "aider_4096": { + "file_count": 70, + "tokens": 3372, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 155, + "tokens": 8922, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "benchmark/EFCore.Benchmarks/Models/AdventureWorks/CountryRegion.cs", + "archetype": "Model/Schema", + "indegree": 67, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 0, + "confidence": 1, + "tokens": 2224, + "total_files": 25, + "must_read_files": [ + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/Address.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/AddressType.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/AdventureWorksContextBase.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/BillOfMaterials.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/BusinessEntity.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/BusinessEntityAddress.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/BusinessEntityContact.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/ContactType.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/CountryRegionCurrency.cs", + "benchmark/EFCore.Benchmarks/Models/AdventureWorks/CreditCard.cs" + ] + }, + "aider_4096": { + "file_count": 68, + "tokens": 3301, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 158, + "tokens": 8764, + "recall": 0.1, + "precision": 0.006 + } + }, + { + "file": "src/EFCore.Relational/Query/IMemberTranslatorPlugin.cs", + "archetype": "Middleware/Plugin", + "indegree": 77, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 3, + "confidence": 1, + "tokens": 2151, + "total_files": 28, + "must_read_files": [ + "src/EFCore.Relational/Query/CollectionResultExpression.cs", + "src/EFCore.Relational/Query/IAggregateMethodCallTranslatorPlugin.cs", + "src/EFCore.Relational/Query/IMethodCallTranslatorPlugin.cs", + "src/EFCore.Relational/Query/ExpressionExtensions.cs", + "src/EFCore.Relational/Query/EnumerableExpression.cs", + "src/EFCore.Relational/Query/IAggregateMethodCallTranslator.cs", + "src/EFCore.Relational/Query/IAggregateMethodCallTranslatorProvider.cs", + "src/EFCore.Relational/Query/IMemberTranslator.cs", + "src/EFCore.Relational/Query/IMemberTranslatorProvider.cs", + "src/EFCore.Relational/Query/IMethodCallTranslator.cs" + ] + }, + "aider_4096": { + "file_count": 83, + "tokens": 4079, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 135, + "tokens": 7801, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore/Storage/Json/IJsonValueReaderWriterSource.cs", + "archetype": "High Fan-in Utility", + "indegree": 185, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 0, + "confidence": 1, + "tokens": 1585, + "total_files": 25, + "must_read_files": [ + "src/EFCore/Storage/Json/JsonBoolReaderWriter.cs", + "src/EFCore/Storage/Json/JsonByteArrayReaderWriter.cs", + "src/EFCore/Storage/Json/JsonByteReaderWriter.cs", + "src/EFCore/Storage/Json/JsonCharReaderWriter.cs", + "src/EFCore/Storage/Json/JsonDateOnlyReaderWriter.cs", + "src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs", + "src/EFCore/Storage/Json/JsonCastValueReaderWriter.cs", + "src/EFCore/Storage/Json/JsonCollectionOfNullableStructsReaderWriter.cs", + "src/EFCore/Storage/Json/JsonCollectionOfReferencesReaderWriter.cs", + "src/EFCore/Storage/Json/JsonCollectionOfStructsReaderWriter.cs" + ] + }, + "aider_4096": { + "file_count": 67, + "tokens": 3267, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 135, + "tokens": 7489, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Relational/Metadata/Builders/ViewColumnBuilder.cs", + "archetype": "Page/Route/Endpoint", + "indegree": 0, + "outdegree": 11, + "cc": { + "must_read_count": 10, + "likely_modify_count": 13, + "test_count": 0, + "confidence": 1, + "tokens": 1160, + "total_files": 23, + "must_read_files": [ + "src/EFCore/Metadata/Internal/IRuntimeTypeBase.cs", + "src/EFCore.Relational/Metadata/Builders/ColumnBuilder`.cs", + "src/EFCore.Relational/Metadata/Builders/IConventionCheckConstraintBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/IConventionDbFunctionParameterBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/CheckConstraintBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/ColumnBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/DbFunctionBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/DbFunctionBuilderBase.cs", + "src/EFCore.Relational/Metadata/Builders/DbFunctionParameterBuilder.cs", + "src/EFCore.Relational/Metadata/Builders/IConventionDbFunctionBuilder.cs" + ] + }, + "aider_4096": { + "file_count": 81, + "tokens": 4007, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 147, + "tokens": 8562, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Cosmos/Storage/Internal/SessionTokenStorageFactory.cs", + "archetype": "Auth/Security", + "indegree": 0, + "outdegree": 13, + "cc": { + "must_read_count": 10, + "likely_modify_count": 11, + "test_count": 2, + "confidence": 1, + "tokens": 1080, + "total_files": 23, + "must_read_files": [ + "src/EFCore/Infrastructure/Internal/ILazyLoaderFactory.cs", + "src/EFCore/Metadata/Internal/IRuntimeNavigation.cs", + "src/EFCore/Infrastructure/IDbContextOptions.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosExecutionStrategy.cs", + "src/EFCore.Cosmos/Storage/Internal/ByteArrayConverter.cs", + "src/EFCore.Cosmos/Storage/Internal/ContainerProperties.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeOnlyReaderWriter.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeSpanReaderWriter.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs" + ] + }, + "aider_4096": { + "file_count": 68, + "tokens": 3272, + "recall": 0.1, + "precision": 0.015 + }, + "aider_8192": { + "file_count": 132, + "tokens": 7502, + "recall": 0.1, + "precision": 0.008 + } + }, + { + "file": "src/EFCore.Relational/Storage/ValueConversion/RelationalConverterMappingHints.cs", + "archetype": "Leaf Component", + "indegree": 0, + "outdegree": 0, + "cc": { + "must_read_count": 0, + "likely_modify_count": 0, + "test_count": 0, + "confidence": 1, + "tokens": 138, + "total_files": 0, + "must_read_files": [] + }, + "aider_4096": { + "file_count": 82, + "tokens": 4076, + "recall": 0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 126, + "tokens": 7339, + "recall": 0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore/Metadata/ConfigurationSourceExtensions.cs", + "archetype": "Config/Constants", + "indegree": 123, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 1, + "confidence": 1, + "tokens": 1705, + "total_files": 26, + "must_read_files": [ + "src/EFCore/Metadata/AdHocMapperDependencies.cs", + "src/EFCore/Metadata/ConfigurationSource.cs", + "src/EFCore/Metadata/ConstructorBinding.cs", + "src/EFCore/Metadata/ContextParameterBinding.cs", + "src/EFCore/Metadata/DefaultValueBinding.cs", + "src/EFCore/Metadata/EntityTypeFullNameComparer.cs", + "src/EFCore/Metadata/AdHocMapper.cs", + "src/EFCore/Metadata/DependencyInjectionParameterBinding.cs", + "src/EFCore/Metadata/IConventionProperty.cs", + "src/EFCore/Metadata/DependencyInjectionMethodParameterBinding.cs" + ] + }, + "aider_4096": { + "file_count": 67, + "tokens": 3295, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 133, + "tokens": 7489, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "benchmark/EFCore.Benchmarks/ChangeTracker/DbSetOperationTests.cs", + "archetype": "Test File", + "indegree": 0, + "outdegree": 1, + "cc": { + "must_read_count": 1, + "likely_modify_count": 0, + "test_count": 0, + "confidence": 1, + "tokens": 398, + "total_files": 1, + "must_read_files": [ + "benchmark/EFCore.Benchmarks/Models/Orders/OrderLine.cs" + ] + }, + "aider_4096": { + "file_count": 68, + "tokens": 3276, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 133, + "tokens": 7620, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Design/Design/Internal/MigrationsOperations.cs", + "archetype": "Database/Migration", + "indegree": 0, + "outdegree": 13, + "cc": { + "must_read_count": 10, + "likely_modify_count": 12, + "test_count": 5, + "confidence": 1, + "tokens": 1140, + "total_files": 27, + "must_read_files": [ + "src/EFCore/Design/Internal/ICSharpRuntimeAnnotationCodeGenerator.cs", + "src/EFCore.Design/Migrations/Design/IMigrationsCodeGenerator.cs", + "src/EFCore/Internal/IDbSetInitializer.cs", + "src/EFCore.Design/Design/Internal/ContextInfo.cs", + "src/EFCore.Design/Design/Internal/DatabaseOperations.cs", + "src/EFCore.Design/Design/Internal/AppServiceProviderFactory.cs", + "src/EFCore.Design/Design/Internal/DesignTimeConnectionStringResolver.cs", + "src/EFCore.Design/Design/Internal/DesignTimeServicesBuilder.cs", + "src/EFCore.Design/Design/Internal/CSharpHelper.cs", + "src/EFCore.Design/Design/Internal/DbContextOperations.cs" + ] + }, + "aider_4096": { + "file_count": 81, + "tokens": 3997, + "recall": 0.1, + "precision": 0.012 + }, + "aider_8192": { + "file_count": 112, + "tokens": 6087, + "recall": 0.1, + "precision": 0.009 + } + }, + { + "file": "src/EFCore/Metadata/AdHocMapper.cs", + "archetype": "Serialization/Validation", + "indegree": 123, + "outdegree": 11, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 0, + "confidence": 1, + "tokens": 1490, + "total_files": 25, + "must_read_files": [ + "src/EFCore/Metadata/Internal/IRuntimeKey.cs", + "src/EFCore/Metadata/AdHocMapperDependencies.cs", + "src/EFCore/Metadata/ConfigurationSource.cs", + "src/EFCore/Metadata/ConfigurationSourceExtensions.cs", + "src/EFCore/Metadata/ConstructorBinding.cs", + "src/EFCore/Metadata/ContextParameterBinding.cs", + "src/EFCore/Metadata/DefaultValueBinding.cs", + "src/EFCore/Metadata/EntityTypeFullNameComparer.cs", + "src/EFCore/Metadata/DependencyInjectionParameterBinding.cs", + "src/EFCore/Metadata/DependencyInjectionMethodParameterBinding.cs" + ] + }, + "aider_4096": { + "file_count": 66, + "tokens": 3221, + "recall": 0.1, + "precision": 0.015 + }, + "aider_8192": { + "file_count": 149, + "tokens": 8628, + "recall": 0.1, + "precision": 0.007 + } + }, + { + "file": "src/EFCore/Metadata/DefaultValueBinding.cs", + "archetype": "Error Handling", + "indegree": 123, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 0, + "confidence": 1, + "tokens": 1759, + "total_files": 25, + "must_read_files": [ + "src/EFCore/Metadata/AdHocMapperDependencies.cs", + "src/EFCore/Metadata/ConfigurationSource.cs", + "src/EFCore/Metadata/ConfigurationSourceExtensions.cs", + "src/EFCore/Metadata/ConstructorBinding.cs", + "src/EFCore/Metadata/ContextParameterBinding.cs", + "src/EFCore/Metadata/EntityTypeFullNameComparer.cs", + "src/EFCore/Metadata/AdHocMapper.cs", + "src/EFCore/Metadata/DependencyInjectionParameterBinding.cs", + "src/EFCore/Metadata/IConventionProperty.cs", + "src/EFCore/Metadata/DependencyInjectionMethodParameterBinding.cs" + ] + }, + "aider_4096": { + "file_count": 70, + "tokens": 3401, + "recall": 0.1, + "precision": 0.014 + }, + "aider_8192": { + "file_count": 135, + "tokens": 7557, + "recall": 0.1, + "precision": 0.007 + } + }, + { + "file": "src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs", + "archetype": "CLI/Command", + "indegree": 33, + "outdegree": 15, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 4, + "confidence": 1, + "tokens": 2036, + "total_files": 29, + "must_read_files": [ + "src/ef/Json.cs", + "src/EFCore/Infrastructure/Internal/ILazyLoaderFactory.cs", + "src/EFCore/Metadata/Internal/IMemberClassifier.cs", + "src/EFCore/Internal/ICollectionLoader`.cs", + "src/EFCore/Diagnostics/Internal/DelegatingDbContextLogger.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosExecutionStrategy.cs", + "src/EFCore.Cosmos/Storage/Internal/ByteArrayConverter.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeOnlyReaderWriter.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeSpanReaderWriter.cs", + "src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs" + ] + }, + "aider_4096": { + "file_count": 69, + "tokens": 3379, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 161, + "tokens": 8884, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Relational/Diagnostics/CommandErrorEventData.cs", + "archetype": "Event/Message", + "indegree": 49, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 0, + "confidence": 1, + "tokens": 2051, + "total_files": 25, + "must_read_files": [ + "src/EFCore.Relational/Diagnostics/BatchEventData.cs", + "src/EFCore.Relational/Diagnostics/ColumnsEventData.cs", + "src/EFCore.Relational/Diagnostics/CommandCorrelatedEventData.cs", + "src/EFCore.Relational/Diagnostics/CommandEndEventData.cs", + "src/EFCore.Relational/Diagnostics/CommandEventData.cs", + "src/EFCore.Relational/Diagnostics/CommandExecutedEventData.cs", + "src/EFCore.Relational/Diagnostics/CommandSource.cs", + "src/EFCore.Relational/Diagnostics/ConnectionCreatedEventData.cs", + "src/EFCore.Relational/Diagnostics/ConnectionCreatingEventData.cs", + "src/EFCore.Relational/Diagnostics/ConnectionEndEventData.cs" + ] + }, + "aider_4096": { + "file_count": 79, + "tokens": 3980, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 133, + "tokens": 7407, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.Interfaces.cs", + "archetype": "Interface/Contract", + "indegree": 20, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 5, + "confidence": 1, + "tokens": 1942, + "total_files": 30, + "must_read_files": [ + "src/EFCore.Design/Scaffolding/Internal/CSharpEntityTypeGenerator.Interfaces.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpNamer.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpUniqueNamer.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpUtilities.cs", + "src/EFCore.Design/Scaffolding/Internal/CallContext.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpEntityTypeGenerator.cs", + "src/EFCore.Design/Scaffolding/Internal/CandidateNamingService.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpModelGenerator.cs", + "src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs" + ] + }, + "aider_4096": { + "file_count": 68, + "tokens": 3311, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 159, + "tokens": 8827, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "src/EFCore.Relational/Query/SqlExpressions/ITableBasedExpression.cs", + "archetype": "Orchestrator", + "indegree": 154, + "outdegree": 10, + "cc": { + "must_read_count": 10, + "likely_modify_count": 15, + "test_count": 3, + "confidence": 1, + "tokens": 1277, + "total_files": 28, + "must_read_files": [ + "src/EFCore.Relational/Query/SqlExpressions/AtTimeZoneExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/CaseExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/CaseWhenClause.cs", + "src/EFCore.Relational/Query/SqlExpressions/CollateExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/ColumnExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/ColumnValueSetter.cs", + "src/EFCore.Relational/Query/SqlExpressions/CrossApplyExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/CrossJoinExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/DeleteExpression.cs", + "src/EFCore.Relational/Query/SqlExpressions/DistinctExpression.cs" + ] + }, + "aider_4096": { + "file_count": 81, + "tokens": 4084, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 131, + "tokens": 7493, + "recall": 0.0, + "precision": 0.0 + } + }, + { + "file": "test/EFCore.SqlServer.FunctionalTests/ComplexTypesTrackingSqlServerTest.cs", + "archetype": "Hotspot", + "indegree": 0, + "outdegree": 8, + "cc": { + "must_read_count": 8, + "likely_modify_count": 0, + "test_count": 0, + "confidence": 1, + "tokens": 700, + "total_files": 8, + "must_read_files": [ + "test/EFCore.SqlServer.FunctionalTests/JsonTypesSqlServerTestBase.cs", + "test/EFCore.SqlServer.FunctionalTests/SqlServerFixture.cs", + "test/EFCore.SqlServer.FunctionalTests/F1SqlServerFixture.cs", + "test/EFCore.SqlServer.FunctionalTests/ManyToManyTrackingSqlServerTestBase.cs", + "test/EFCore.SqlServer.FunctionalTests/QueryExpressionInterceptionSqlServerTestBase.cs", + "test/EFCore.SqlServer.FunctionalTests/SpatialSqlServerFixture.cs", + "test/EFCore.SqlServer.FunctionalTests/StoreGeneratedSqlServerTestBase.cs", + "test/EFCore.SqlServer.FunctionalTests/SqlServerValueGenerationScenariosTestBase.cs" + ] + }, + "aider_4096": { + "file_count": 67, + "tokens": 3279, + "recall": 0.0, + "precision": 0.0 + }, + "aider_8192": { + "file_count": 133, + "tokens": 7473, + "recall": 0.0, + "precision": 0.0 + } + } + ], + "repomix_tokens": 23234107, + "summary": { + "avg_cc_tokens": 1420, + "avg_cc_files": 22.1, + "avg_aider_4k_recall": 0.024, + "avg_aider_8k_recall": 0.029, + "avg_aider_4k_precision": 0.003, + "avg_aider_8k_precision": 0.002 + } } }, "aggregate": { - "total_files_analyzed": 51, - "total_repos": 6, - "avg_cc_tokens": 1091, - "avg_cc_files": 17.0, - "avg_aider_4k_recall": 0.182, - "avg_aider_8k_recall": 0.229, - "avg_aider_4k_precision": 0.021, - "avg_aider_8k_precision": 0.015 + "total_files_analyzed": 68, + "total_repos": 7, + "avg_cc_tokens": 1174, + "avg_cc_files": 18.3, + "avg_aider_4k_recall": 0.139, + "avg_aider_8k_recall": 0.175, + "avg_aider_4k_precision": 0.016, + "avg_aider_8k_precision": 0.011 } } \ No newline at end of file diff --git a/scripts/compare/run_comparison.sh b/scripts/compare/run_comparison.sh index 2ffd3d7..ef34898 100755 --- a/scripts/compare/run_comparison.sh +++ b/scripts/compare/run_comparison.sh @@ -58,6 +58,7 @@ declare -a REPOS=( "terraform|go|" "spring-boot|java|" "tokio|rust|" + "efcore|csharp|https://github.com/dotnet/efcore.git" ) # ─── Preflight checks ──────────────────────────────────────────────────────── diff --git a/scripts/score_csharp.py b/scripts/score_csharp.py new file mode 100644 index 0000000..db61e8a --- /dev/null +++ b/scripts/score_csharp.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Score C# repos against the grader rubric using the contextception CLI.""" +import json +import subprocess +import sys +import os + +BIN = os.path.join(os.path.dirname(__file__), "..", "bin", "contextception") +REPOS = { + "jellyfin": os.path.expanduser("~/Repositories/test-corpus/jellyfin"), + "efcore": os.path.expanduser("~/Repositories/test-corpus/efcore"), + "avalonia": os.path.expanduser("~/Repositories/test-corpus/avalonia"), + "orleans": os.path.expanduser("~/Repositories/test-corpus/orleans"), + "roslyn": os.path.expanduser("~/Repositories/test-corpus/roslyn"), +} + +def run(args, cwd): + r = subprocess.run(args, capture_output=True, text=True, cwd=cwd, timeout=120) + return r.stdout + +def index_repo(repo_path): + run([BIN, "index"], repo_path) + +def get_archetypes(repo_path): + out = run([BIN, "archetypes"], repo_path) + return json.loads(out) + +def analyze(repo_path, file): + out = run([BIN, "analyze", "--json", file], repo_path) + return json.loads(out) + +def grade_output(data): + """Simplified grading matching the Go grader logic.""" + scores = {} + + # must_read (weight 0.40) + mr_score = 4.0 + if data["confidence"] < 0.8: + mr_score -= 1.0 + elif data["confidence"] < 0.95: + mr_score -= 0.5 + is_csharp = data["subject"].endswith(".cs") + if data["must_read"]: + eligible = sum(1 for e in data["must_read"] if e.get("direction", "") not in ("", "same_package") and not is_csharp) + with_syms = sum(1 for e in data["must_read"] if e.get("direction", "") not in ("", "same_package") and not is_csharp and e.get("symbols")) + with_dir = sum(1 for e in data["must_read"] if e.get("direction")) + if eligible > 0 and with_syms / eligible < 0.3: + mr_score -= 0.5 + if with_dir / len(data["must_read"]) < 0.5: + mr_score -= 0.5 + scores["must_read"] = max(1.0, min(4.0, mr_score)) + + # likely_modify (weight 0.20) + lm_entries = sum(len(v) for v in data.get("likely_modify", {}).values()) + if lm_entries == 0: + scores["likely_modify"] = 3.0 + else: + lm_score = 4.0 + tiers = {"high": 0, "medium": 0, "low": 0} + signals = set() + for entries in data.get("likely_modify", {}).values(): + for e in entries: + tiers[e.get("confidence", "low")] += 1 + for s in e.get("signals", []): + sig = s.split(":")[0] if ":" in s else s + signals.add(sig) + if lm_entries >= 3 and (tiers["high"] == lm_entries or tiers["low"] == lm_entries): + lm_score -= 0.5 + if len(signals) < 2 and lm_entries >= 3: + lm_score -= 0.5 + scores["likely_modify"] = max(1.0, min(4.0, lm_score)) + + # tests (weight 0.15) + tests = data.get("tests", []) + if not tests: + from pathlib import PurePosixPath + stem = PurePosixPath(data["subject"]).stem + is_test = stem.endswith("Test") or stem.endswith("Tests") or stem.startswith("Test") or stem.endswith("Spec") + if is_test: + scores["tests"] = 4.0 + elif data.get("tests_note", "") == "no test files found in nearby directories": + scores["tests"] = 3.0 + else: + scores["tests"] = 2.0 + else: + t_score = 4.0 + direct = sum(1 for t in tests if t.get("direct")) + if direct == 0: + t_score -= 1.0 + if len(tests) >= 2 and direct == 0: + t_score -= 0.5 + scores["tests"] = max(1.0, min(4.0, t_score)) + + # related (weight 0.15) + rel_entries = sum(len(v) for v in data.get("related", {}).values()) + if rel_entries == 0: + scores["related"] = 2.5 + else: + r_score = 4.0 + useful = False + for entries in data.get("related", {}).values(): + for e in entries: + for s in e.get("signals", []): + if any(k in s for k in ["distance:2", "co_change", "structural", "hidden_coupling", + "two_hop", "transitive_caller", "same_package", "high_churn", + "hotspot", "imports", "imported_by"]): + useful = True + if not useful and rel_entries >= 3: + r_score -= 0.5 + scores["related"] = max(1.0, min(4.0, r_score)) + + # blast_radius (weight 0.10) + br = data.get("blast_radius") + if not br: + scores["blast_radius"] = 1.0 + else: + br_score = 4.0 + if br.get("level") not in ("low", "medium", "high"): + br_score -= 2.0 + if not br.get("detail"): + br_score -= 1.0 + if br.get("level") == "high" and lm_entries < 3: + br_score -= 0.5 + if br.get("level") == "low" and lm_entries > 10: + br_score -= 0.5 + scores["blast_radius"] = max(1.0, min(4.0, br_score)) + + overall = (scores["must_read"] * 0.40 + scores["likely_modify"] * 0.20 + + scores["tests"] * 0.15 + scores["related"] * 0.15 + + scores["blast_radius"] * 0.10) + return scores, overall + +def main(): + results = {} + for name, path in REPOS.items(): + if not os.path.isdir(path): + print(f" SKIP {name} (not found at {path})") + continue + + print(f"\n{'='*60}") + print(f" {name}") + print(f"{'='*60}") + + index_repo(path) + archetypes = get_archetypes(path) + + file_scores = [] + section_totals = {"must_read": 0, "likely_modify": 0, "tests": 0, "related": 0, "blast_radius": 0} + + for arch in archetypes: + f = arch["file"] + try: + data = analyze(path, f) + scores, overall = grade_output(data) + file_scores.append((f, arch["archetype"], scores, overall)) + for k, v in scores.items(): + section_totals[k] += v + except Exception as e: + print(f" ERROR {f}: {e}") + + if not file_scores: + print(" No files scored") + continue + + n = len(file_scores) + avg = sum(o for _, _, _, o in file_scores) / n + + # Print per-file breakdown + for f, arch, scores, overall in sorted(file_scores, key=lambda x: x[3]): + grade = "A" if overall >= 3.5 else "B" if overall >= 2.5 else "C" if overall >= 1.5 else "D" + print(f" {grade} {overall:.2f} mr={scores['must_read']:.1f} lm={scores['likely_modify']:.1f} t={scores['tests']:.1f} r={scores['related']:.1f} br={scores['blast_radius']:.1f} {os.path.basename(f)} [{arch}]") + + # Section averages + print(f"\n Sections: mr={section_totals['must_read']/n:.2f} lm={section_totals['likely_modify']/n:.2f} t={section_totals['tests']/n:.2f} r={section_totals['related']/n:.2f} br={section_totals['blast_radius']/n:.2f}") + + grade = "A" if avg >= 3.5 else "B" if avg >= 2.5 else "C" if avg >= 1.5 else "D" + print(f" Overall: {avg:.2f} ({grade})") + results[name] = avg + + print(f"\n{'='*60}") + print(f" AGGREGATE") + print(f"{'='*60}") + if results: + total = sum(results.values()) / len(results) + for name, score in sorted(results.items(), key=lambda x: x[1]): + grade = "A" if score >= 3.5 else "B" if score >= 2.5 else "C" if score >= 1.5 else "D" + print(f" {grade} {score:.2f} {name}") + grade = "A" if total >= 3.5 else "B" if total >= 2.5 else "C" if total >= 1.5 else "D" + print(f"\n Aggregate: {total:.2f} ({grade})") + +if __name__ == "__main__": + main()