Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ jobs:
with:
distribution: 'temurin'
java-version: '21'
- uses: actions/setup-dotnet@v4 # for the C# adapter tests (else they skip)
with:
dotnet-version: '9.0.x'
- name: Install the TypeScript toolchain
run: npm ci # provides the `typescript` the tshash helper resolves
- name: Sync deps
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ impl extension: a `.go` impl uses `go` (`.hashloom/config.json` → `"go"`); a
and auto-detects the test runner (vitest / jest, else Node's `node:test`); a
`.java` impl uses `java` (→ `"java"`, JDK >= 11) and auto-detects the runner from
the build manifest (`pom.xml` → Maven, `build.gradle` → Gradle, committed
`mvnw`/`gradlew` wrappers preferred).
`mvnw`/`gradlew` wrappers preferred); a `.cs` impl uses `dotnet` (→ `"dotnet"`,
.NET SDK >= 8) and runs `dotnet test` on the root project/solution.

## How to run

Expand All @@ -117,4 +118,6 @@ hash-stability rules are enforced, not just documented. Keep it green.
Python >=3.10. Deps: mcp, pyyaml, tiktoken, pytest. TypeScript contracts also
need Node >=22.6 and the project's own `typescript` (CI installs a repo-local one
via `npm ci`; `node_modules/` is gitignored). Java contracts need a JDK >=11 plus
Maven or Gradle (CI installs Temurin; Maven ships on the runner).
Maven or Gradle (CI installs Temurin; Maven ships on the runner). C# contracts
need the .NET SDK >=8 (CI installs 9; the Roslyn hash helper compiles from the
SDK's own toolchain, no packages).
1 change: 1 addition & 0 deletions ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ launch follow-ups, with status as of the 0.1.0 release (shipped 2026-06-23).
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.
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.
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.
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.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ A contract an agent reverse-engineers from existing code can declare that it has

- **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.
- **Impl hash**: sha256 over the normalised AST of the implementation, so reformatting and comment edits never bust the cache. Docstrings are stripped too.
- **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)).
- **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)).

## MCP tools (the entire surface)

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

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

Expand Down
5 changes: 2 additions & 3 deletions contracts/LanguageAdapter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: LanguageAdapter
signature: "Protocol: per-language impl/test-source hashing, toolchain resolution, and test running — six methods, one adapter per impl extension"
deps: [HashloomError]
invariants:
- 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
- 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
- impl_hash is formatting/comment/doc-neutral and behaviour-sensitive; a missing file or name raises impl_not_found, unparseable source impl_syntax_error
- test_source_hash never raises — unresolvable node ids degrade to their literal id, and node-id order never changes the hash
- "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"
Expand All @@ -14,5 +14,4 @@ tests:
- tests/test_dep_identity.py::test_bare_root_identity_is_byte_identical_to_version_only
- tests/test_dep_identity.py::test_python_identity_gains_dep_suffix
impl: src/hashloom/langs/__init__.py::LanguageAdapter
status: confirmed

status: inferred
13 changes: 10 additions & 3 deletions src/hashloom/langs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
Each adapter knows how to, for one language: hash an implementation's normalised
AST, hash a test's source, read impl source for the content-addressed blob,
resolve the language's toolchain, and run tests. Python is the default; Go,
TypeScript, and Java are the non-Python adapters.
TypeScript, Java, and C# are the non-Python adapters.

The adapter is chosen by the impl file's extension (`adapter_for`), so contracts
gain no new syntax: a `.go` impl routes to Go, `.ts`/`.tsx`/`.mts`/`.cts` to
TypeScript, `.java` to Java, everything else to Python.
TypeScript, `.java` to Java, `.cs` to C#, everything else to Python.
"""

from __future__ import annotations
Expand All @@ -34,11 +34,12 @@ def run_tests(
_GO: LanguageAdapter | None = None
_TS: LanguageAdapter | None = None
_JAVA: LanguageAdapter | None = None
_CSHARP: LanguageAdapter | None = None


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

_JAVA = JavaAdapter()
return _JAVA
if path.endswith(".cs"):
if _CSHARP is None:
from .csharp import CSharpAdapter

_CSHARP = CSharpAdapter()
return _CSHARP
if _PYTHON is None:
from .python import PythonAdapter

Expand Down
Loading
Loading