Skip to content

Commit 6056518

Browse files
davet47dt
andauthored
langs: C# adapter — Roslyn token-stream hashing via the SDK's own csc, dotnet test runner (#95)
- .cs routes to CSharpAdapter; hash helper compiled once per SDK version with the SDK's bundled Roslyn (no packages, no network), cached in the user cache dir keyed by SDK version + helper source - dotnet test with the VSTest FullyQualifiedName~ filter (xUnit/NUnit/ MSTest); file-stem class convention, Outer+Inner nested spelling - identity: dotnet <sdk-version> + deps suffix over packages.lock.json / Directory.Packages.props - LanguageAdapter contract: .cs added to the routing invariant, status confirmed -> inferred pending review (hash unchanged: invariants are hash-free) - CI installs the .NET 9 SDK; ISSUES.md #23 files the example-project and NUnit/MSTest/MTP live-coverage follow-ups Co-authored-by: dt <david@e3.com.au>
1 parent f0db9ad commit 6056518

10 files changed

Lines changed: 916 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ jobs:
3535
with:
3636
distribution: 'temurin'
3737
java-version: '21'
38+
- uses: actions/setup-dotnet@v4 # for the C# adapter tests (else they skip)
39+
with:
40+
dotnet-version: '9.0.x'
3841
- name: Install the TypeScript toolchain
3942
run: npm ci # provides the `typescript` the tshash helper resolves
4043
- name: Sync deps

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ impl extension: a `.go` impl uses `go` (`.hashloom/config.json` → `"go"`); a
102102
and auto-detects the test runner (vitest / jest, else Node's `node:test`); a
103103
`.java` impl uses `java` (→ `"java"`, JDK >= 11) and auto-detects the runner from
104104
the build manifest (`pom.xml` → Maven, `build.gradle` → Gradle, committed
105-
`mvnw`/`gradlew` wrappers preferred).
105+
`mvnw`/`gradlew` wrappers preferred); a `.cs` impl uses `dotnet` (→ `"dotnet"`,
106+
.NET SDK >= 8) and runs `dotnet test` on the root project/solution.
106107

107108
## How to run
108109

@@ -117,4 +118,6 @@ hash-stability rules are enforced, not just documented. Keep it green.
117118
Python >=3.10. Deps: mcp, pyyaml, tiktoken, pytest. TypeScript contracts also
118119
need Node >=22.6 and the project's own `typescript` (CI installs a repo-local one
119120
via `npm ci`; `node_modules/` is gitignored). Java contracts need a JDK >=11 plus
120-
Maven or Gradle (CI installs Temurin; Maven ships on the runner).
121+
Maven or Gradle (CI installs Temurin; Maven ships on the runner). C# contracts
122+
need the .NET SDK >=8 (CI installs 9; the Roslyn hash helper compiles from the
123+
SDK's own toolchain, no packages).

ISSUES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ launch follow-ups, with status as of the 0.1.0 release (shipped 2026-06-23).
3535
20. **Dep-source discovery is root-only**: the dependency-set suffix probes fixed filenames at the project root (matching the runner-detection precedent). Monorepo/multi-file shapes escape it: `go.work`, pnpm/yarn workspaces, Maven child modules, `requirements.txt` `-r`/`-c` includes (only the root file is hashed), `go.mod` `replace ... => ../local` directives (local replaces never enter `go.sum`), Gradle version catalogs (`gradle/libs.versions.toml` — versions referenced from `build.gradle` live outside the hashed file). All lean unsound-quiet (a change that should bust doesn't) — revocation covers discoveries; recursive/workspace-aware discovery is a conscious future change.
3636
21. **Strict resolved-env grain**: the identity hashes the *declared* set (the committed lockfile), not the *installed* environment. An opt-in stricter grain (e.g. `importlib.metadata` enumeration of the resolved interpreter) would catch venv-disagrees-with-lockfile drift, but any key-affecting per-client knob permanently partitions the shared cache by knob population — the reason config knobs were rejected for the base feature. If built, it must be a project-level (committed) setting, not client config.
3737
22. **`serve --python` verify/status asymmetry** (pre-existing): the MCP server threads `--python` into `api.verify` but `api.status` calls `toolchain_identity(root)` with no override, so under a version-different override `status` can call dirty what `verify` just greened. The dependency-set suffix is override-independent, so it cannot widen this gap — but the version core can. Fix candidates: thread the override into `build_server`'s status closure, or report both identities.
38+
23. **C# example project and live-runner breadth**: the C# adapter shipped with a hermetic xUnit e2e in `tests/test_csharp_adapter.py`, but there is no `examples/csharp-*` project (Go/TS/Java each have one) and no live NUnit/MSTest run in the suite — both ride the same VSTest `FullyQualifiedName~` filter and console-logger output as xUnit, so they're expected-compatible but unproven, the same gap #13 tracks for vitest/jest. Also untested live: xunit v3 / Microsoft.Testing.Platform projects, whose `dotnet test` output format differs (failures there surface as `tests_failed_to_run`, conservative). Follow-up: add an example project, plus an NUnit fixture and an MTP-output parse case.

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ A contract an agent reverse-engineers from existing code can declare that it has
106106

107107
- **Contract hash**: sha256 over a canonical form: keys sorted, whitespace normalised, comments stripped, example order preserved, dep order ignored. `impl`, `tests`, `invariants`, and `status` are excluded, so **relocating files never invalidates**, rewording an invariant is free, and confirming an inferred contract never invalidates anything. Invariants are documentation, not a machine obligation; the real check is the tests, whose source is in the verification key.
108108
- **Impl hash**: sha256 over the normalised AST of the implementation, so reformatting and comment edits never bust the cache. Docstrings are stripped too.
109-
- **Verification key**: `(contract hash, impl hash, test-source hash, toolchain identity, transitive dep contract hashes)`. Hashloom caches verification results, keyed so that a change to any contract in the closure, to the implementation, to a test's own source, or to the toolchain identity forces a re-run. The identity is the toolchain version (`python 3.11.7`, `go 1.21.5`, `node <v> ts <v>`, `java 21.0.3`) plus, when the project commits a dependency source at its root (`uv.lock`, `go.sum`, `package-lock.json`, `pom.xml`, ...), a ` deps <file>=<hash>` digest of it — so a green from an environment with a different declared dependency set is never trusted, and a 3.11 pass is never served to 3.13. The grain is the *committed declared* set (never OS/arch — CI greens still serve every platform); a venv that disagrees with its own lockfile is outside the key, which is one of the things shared-cache revocation exists for. Failures are never served from cache. Two caveats. A cached pass assumes deterministic tests, so a green result that depended on wall-clock time, network, or randomness can outlive the condition that made it pass. And the test-source hash covers each test function's own normalised AST, not the conftest fixtures or helpers it calls, so changing only those will not force a re-run yet (see [Roadmap](ROADMAP.md)).
109+
- **Verification key**: `(contract hash, impl hash, test-source hash, toolchain identity, transitive dep contract hashes)`. Hashloom caches verification results, keyed so that a change to any contract in the closure, to the implementation, to a test's own source, or to the toolchain identity forces a re-run. The identity is the toolchain version (`python 3.11.7`, `go 1.21.5`, `node <v> ts <v>`, `java 21.0.3`, `dotnet 9.0.303`) plus, when the project commits a dependency source at its root (`uv.lock`, `go.sum`, `package-lock.json`, `pom.xml`, ...), a ` deps <file>=<hash>` digest of it — so a green from an environment with a different declared dependency set is never trusted, and a 3.11 pass is never served to 3.13. The grain is the *committed declared* set (never OS/arch — CI greens still serve every platform); a venv that disagrees with its own lockfile is outside the key, which is one of the things shared-cache revocation exists for. Failures are never served from cache. Two caveats. A cached pass assumes deterministic tests, so a green result that depended on wall-clock time, network, or randomness can outlive the condition that made it pass. And the test-source hash covers each test function's own normalised AST, not the conftest fixtures or helpers it calls, so changing only those will not force a re-run yet (see [Roadmap](ROADMAP.md)).
110110

111111
## MCP tools (the entire surface)
112112

@@ -153,6 +153,11 @@ semantics hold per language:
153153
`pom.xml` routes to Maven, `build.gradle`/`build.gradle.kts` to Gradle, and a
154154
committed `mvnw`/`gradlew` wrapper is preferred over the PATH binary. Needs a
155155
JDK >= 11 plus Maven or Gradle; config key `{"java": "..."}`.
156+
- **C#** (`.cs`): hashes via a Roslyn token-stream helper that hashloom compiles
157+
once per SDK version with the SDK's *own* `csc` against its bundled
158+
`Microsoft.CodeAnalysis` (no packages, no network), runs `dotnet test` on the
159+
root project/solution with a `FullyQualifiedName~` filter (xUnit, NUnit, and
160+
MSTest all ride it). Needs the .NET SDK >= 8; config key `{"dotnet": "..."}`.
156161

157162
Python stays the default; a project can mix languages freely.
158163

contracts/LanguageAdapter.yaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: LanguageAdapter
22
signature: "Protocol: per-language impl/test-source hashing, toolchain resolution, and test running — six methods, one adapter per impl extension"
33
deps: [HashloomError]
44
invariants:
5-
- adapter_for routes on the impl path's extension (.go, .ts/.tsx/.mts/.cts, .java) with Python as the default — one lazy singleton per language, no new contract syntax
5+
- adapter_for routes on the impl path's extension (.go, .ts/.tsx/.mts/.cts, .java, .cs) with Python as the default — one lazy singleton per language, no new contract syntax
66
- impl_hash is formatting/comment/doc-neutral and behaviour-sensitive; a missing file or name raises impl_not_found, unparseable source impl_syntax_error
77
- test_source_hash never raises — unresolvable node ids degrade to their literal id, and node-id order never changes the hash
88
- "toolchains resolve per language (override, then the .hashloom/config.json key, then PATH/auto-detect) and identities are machine-independent strings: the toolchain version plus, when a well-known lockfile/manifest exists at the project root, a ` deps <file>=<sha256-12>` suffix over its CRLF-normalised bytes — never OS/arch, so cross-OS greens share whenever the committed dependency set matches; a project with no dependency source keeps the version-only identity, so absence never busts keys"
@@ -14,5 +14,4 @@ tests:
1414
- tests/test_dep_identity.py::test_bare_root_identity_is_byte_identical_to_version_only
1515
- tests/test_dep_identity.py::test_python_identity_gains_dep_suffix
1616
impl: src/hashloom/langs/__init__.py::LanguageAdapter
17-
status: confirmed
18-
17+
status: inferred

src/hashloom/langs/__init__.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
Each adapter knows how to, for one language: hash an implementation's normalised
44
AST, hash a test's source, read impl source for the content-addressed blob,
55
resolve the language's toolchain, and run tests. Python is the default; Go,
6-
TypeScript, and Java are the non-Python adapters.
6+
TypeScript, Java, and C# are the non-Python adapters.
77
88
The adapter is chosen by the impl file's extension (`adapter_for`), so contracts
99
gain no new syntax: a `.go` impl routes to Go, `.ts`/`.tsx`/`.mts`/`.cts` to
10-
TypeScript, `.java` to Java, everything else to Python.
10+
TypeScript, `.java` to Java, `.cs` to C#, everything else to Python.
1111
"""
1212

1313
from __future__ import annotations
@@ -34,11 +34,12 @@ def run_tests(
3434
_GO: LanguageAdapter | None = None
3535
_TS: LanguageAdapter | None = None
3636
_JAVA: LanguageAdapter | None = None
37+
_CSHARP: LanguageAdapter | None = None
3738

3839

3940
def adapter_for(impl: str) -> LanguageAdapter:
4041
"""Pick the adapter by the impl file's extension; default to Python."""
41-
global _PYTHON, _GO, _TS, _JAVA
42+
global _PYTHON, _GO, _TS, _JAVA, _CSHARP
4243
path = impl.partition("::")[0]
4344
if path.endswith(".go"):
4445
if _GO is None:
@@ -58,6 +59,12 @@ def adapter_for(impl: str) -> LanguageAdapter:
5859

5960
_JAVA = JavaAdapter()
6061
return _JAVA
62+
if path.endswith(".cs"):
63+
if _CSHARP is None:
64+
from .csharp import CSharpAdapter
65+
66+
_CSHARP = CSharpAdapter()
67+
return _CSHARP
6168
if _PYTHON is None:
6269
from .python import PythonAdapter
6370

0 commit comments

Comments
 (0)