codeterrain 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codeterrain/__init__.py +3 -0
- codeterrain/_guard.py +243 -0
- codeterrain/_pytest_dump_plugin.py +253 -0
- codeterrain/_routes_runner.py +119 -0
- codeterrain/budget.py +128 -0
- codeterrain/cache.py +618 -0
- codeterrain/calls_join.py +416 -0
- codeterrain/calls_static.py +1099 -0
- codeterrain/cli.py +1447 -0
- codeterrain/code_static.py +2411 -0
- codeterrain/config.py +331 -0
- codeterrain/executables.py +130 -0
- codeterrain/fixtures_order.py +77 -0
- codeterrain/fixtures_static.py +501 -0
- codeterrain/hookspecs.py +25 -0
- codeterrain/impact.py +725 -0
- codeterrain/languages.py +293 -0
- codeterrain/plugins/__init__.py +0 -0
- codeterrain/plugins/fastapi_routes.py +193 -0
- codeterrain/plugins/manifest_join.py +107 -0
- codeterrain/protocol.py +138 -0
- codeterrain/routes_view.py +61 -0
- codeterrain/sandbox.py +565 -0
- codeterrain/views.py +198 -0
- codeterrain-0.3.0.dist-info/METADATA +295 -0
- codeterrain-0.3.0.dist-info/RECORD +30 -0
- codeterrain-0.3.0.dist-info/WHEEL +4 -0
- codeterrain-0.3.0.dist-info/entry_points.txt +2 -0
- codeterrain-0.3.0.dist-info/licenses/LICENSE +201 -0
- codeterrain-0.3.0.dist-info/licenses/NOTICE +4 -0
codeterrain/views.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Which files each cached view depends on, and how many of them moved since it was built."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from codeterrain import __version__, calls_static, protocol
|
|
15
|
+
from codeterrain.cache import Cache, ViewRecord, changed_files
|
|
16
|
+
from codeterrain.config import Config
|
|
17
|
+
from codeterrain.executables import resolve_executable
|
|
18
|
+
from codeterrain.sandbox import SandboxError, probe_versions
|
|
19
|
+
|
|
20
|
+
STATIC_VIEWS = ("fixtures", "calls")
|
|
21
|
+
SLOW_VIEWS = ("order", "routes")
|
|
22
|
+
ALL_VIEWS = STATIC_VIEWS + SLOW_VIEWS
|
|
23
|
+
SCHEMAS = {"order": protocol.ORDER_SCHEMA, "routes": protocol.ROUTES_SCHEMA}
|
|
24
|
+
CONFIG_FILES = ("pyproject.toml", "codeterrain.toml", "pytest.ini", "setup.cfg", "tox.ini")
|
|
25
|
+
#: Config fields that do not change what an extraction produces: ``root`` (the cache is per
|
|
26
|
+
#: checkout), ``source`` (where the values came from, not the values), ``route_manifest`` and
|
|
27
|
+
#: ``manifest_keys`` (read at query time by the access join, never by the extractors), and
|
|
28
|
+
#: ``ignored`` and ``user_config`` (reporting only: dropped values never reach the child, and the
|
|
29
|
+
#: values in use are keyed under their own names), and ``client_paths``, ``path_prefix`` and
|
|
30
|
+
#: ``client_repos`` (the frontend calls view, whose own fingerprint is the files it read, and the
|
|
31
|
+
#: join, which runs at query time).
|
|
32
|
+
KEY_EXEMPT = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"root",
|
|
35
|
+
"source",
|
|
36
|
+
"route_manifest",
|
|
37
|
+
"manifest_keys",
|
|
38
|
+
"ignored",
|
|
39
|
+
"user_config",
|
|
40
|
+
"client_paths",
|
|
41
|
+
"path_prefix",
|
|
42
|
+
"client_repos",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
SITE_PACKAGES = ("Lib/site-packages", "lib/python*/site-packages", "lib/python*/dist-packages")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _site_dirs(prefix: Path) -> list[Path]:
|
|
49
|
+
found: list[Path] = []
|
|
50
|
+
for pattern in SITE_PACKAGES:
|
|
51
|
+
found.extend(p for p in prefix.glob(pattern) if p.is_dir())
|
|
52
|
+
return sorted(found)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def interpreter_fingerprint(
|
|
56
|
+
python: str, config: Config | None = None, cache: Cache | None = None
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""What identifies the target runtime: the interpreter path, its venv config and the names
|
|
59
|
+
(with versions) of the distributions installed beside it. Falls back to the version probe when
|
|
60
|
+
no site-packages sits next to the interpreter; the probe (python, prefix, site dirs) is cached
|
|
61
|
+
against the interpreter file, but the ``*.dist-info`` listing of those site dirs is read on
|
|
62
|
+
every call, so a ``pip install`` behind a launcher or shim still changes the key."""
|
|
63
|
+
try:
|
|
64
|
+
exe = Path(resolve_executable(python, untrusted=config.root if config else None))
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
exe = Path(os.path.abspath(python))
|
|
67
|
+
info: dict[str, Any] = {"path": exe.as_posix()}
|
|
68
|
+
dists: list[str] = []
|
|
69
|
+
for prefix in (exe.parent, exe.parent.parent):
|
|
70
|
+
cfg = prefix / "pyvenv.cfg"
|
|
71
|
+
if cfg.is_file() and "pyvenv_cfg" not in info:
|
|
72
|
+
info["pyvenv_cfg"] = cfg.read_text(encoding="utf-8", errors="replace")
|
|
73
|
+
for site in _site_dirs(prefix):
|
|
74
|
+
with os.scandir(site) as entries:
|
|
75
|
+
dists.extend(e.name for e in entries if e.name.endswith(".dist-info"))
|
|
76
|
+
if dists:
|
|
77
|
+
info["dist_info"] = sorted(set(dists))
|
|
78
|
+
elif config is not None and cache is not None:
|
|
79
|
+
try:
|
|
80
|
+
probe = cache.cached_probe(exe, lambda: _stable_probe(python, config, cache))
|
|
81
|
+
except SandboxError:
|
|
82
|
+
probe = None
|
|
83
|
+
info["probe"] = probe
|
|
84
|
+
if probe is not None:
|
|
85
|
+
info["dist_info"] = _listed_dists(probe.get("sites"))
|
|
86
|
+
return info
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _stable_probe(python: str, config: Config, cache: Cache) -> dict[str, Any]:
|
|
90
|
+
"""The probe minus its distribution listing, which is read live (see above)."""
|
|
91
|
+
found = probe_versions(python, config, cache.dir)
|
|
92
|
+
return {k: v for k, v in found.items() if k != "dists"}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _listed_dists(sites: Any) -> list[str]:
|
|
96
|
+
names: set[str] = set()
|
|
97
|
+
for site in sites if isinstance(sites, list) else []:
|
|
98
|
+
try:
|
|
99
|
+
with os.scandir(site) as entries:
|
|
100
|
+
names.update(e.name for e in entries if e.name.endswith(".dist-info"))
|
|
101
|
+
except OSError:
|
|
102
|
+
continue
|
|
103
|
+
return sorted(names)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def pass_env_digests(names: tuple[str, ...], key: bytes) -> dict[str, str | None]:
|
|
107
|
+
"""HMAC-SHA256 of ``NAME=value`` under the cache's own random key for each listed variable
|
|
108
|
+
(None when unset); never the value, and not guessable offline from a copied view file."""
|
|
109
|
+
return {
|
|
110
|
+
name: (
|
|
111
|
+
hmac.new(key, f"{name}={os.environ[name]}".encode(), hashlib.sha256).hexdigest()
|
|
112
|
+
if name in os.environ
|
|
113
|
+
else None
|
|
114
|
+
)
|
|
115
|
+
for name in names
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def view_key(cache: Cache, config: Config, python: str, view: str) -> str:
|
|
120
|
+
"""Hash of everything besides the tracked files that a slow view's content depends on."""
|
|
121
|
+
material = {
|
|
122
|
+
"config": {k: v for k, v in config.as_dict().items() if k not in KEY_EXEMPT},
|
|
123
|
+
"pass_env_values": pass_env_digests(
|
|
124
|
+
config.pass_env, cache.secret_key() if config.pass_env else b""
|
|
125
|
+
),
|
|
126
|
+
"interpreter": interpreter_fingerprint(python, config, cache),
|
|
127
|
+
"codeterrain": __version__,
|
|
128
|
+
"protocol": SCHEMAS[view],
|
|
129
|
+
}
|
|
130
|
+
return hashlib.sha256(json.dumps(material, sort_keys=True).encode("utf-8")).hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def tracked_fingerprint(cache: Cache, config: Config) -> dict[str, str]:
|
|
134
|
+
"""Every Python file under the root (conftest.py included), plus the project config files
|
|
135
|
+
(pyproject.toml, pytest.ini, setup.cfg, tox.ini) and the route manifest."""
|
|
136
|
+
fingerprint = cache.scan(None)
|
|
137
|
+
extras = [config.root / name for name in CONFIG_FILES]
|
|
138
|
+
if config.route_manifest is not None:
|
|
139
|
+
extras.append(config.route_manifest)
|
|
140
|
+
fingerprint.update(cache.hash_files(extras))
|
|
141
|
+
return fingerprint
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def stale_files(cache: Cache, config: Config, record: ViewRecord) -> int:
|
|
145
|
+
"""Files that differ now from what ``record`` was built from."""
|
|
146
|
+
if record.view == calls_static.VIEW:
|
|
147
|
+
current = calls_static.current_fingerprint(cache, config)
|
|
148
|
+
elif record.view in STATIC_VIEWS:
|
|
149
|
+
current = cache.scan(config.test_paths)
|
|
150
|
+
else:
|
|
151
|
+
current = tracked_fingerprint(cache, config)
|
|
152
|
+
cache.flush()
|
|
153
|
+
return changed_files(record.fingerprint, current)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class Served:
|
|
158
|
+
record: ViewRecord
|
|
159
|
+
stale: int
|
|
160
|
+
built_now: bool
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def serve_slow(
|
|
164
|
+
cache: Cache,
|
|
165
|
+
config: Config,
|
|
166
|
+
view: str,
|
|
167
|
+
schema: str,
|
|
168
|
+
build: Callable[[], dict[str, Any]],
|
|
169
|
+
python: str,
|
|
170
|
+
fresh: bool = False,
|
|
171
|
+
notify: Callable[[str], None] = lambda message: None,
|
|
172
|
+
) -> Served:
|
|
173
|
+
"""The cached slow-tier view, stamped stale when files moved; built only if absent or ``fresh``.
|
|
174
|
+
|
|
175
|
+
A cached document whose schema this core no longer reads, or whose key (config, interpreter,
|
|
176
|
+
versions) differs from the current one, is treated as absent and rebuilt.
|
|
177
|
+
"""
|
|
178
|
+
key = view_key(cache, config, python, view)
|
|
179
|
+
record = cache.read_view(view)
|
|
180
|
+
if record is not None and not record.verified:
|
|
181
|
+
# A view this core wrote and signed was checked before it was written.
|
|
182
|
+
try:
|
|
183
|
+
protocol.check(record.data, schema)
|
|
184
|
+
except protocol.ProtocolError:
|
|
185
|
+
record = None
|
|
186
|
+
if record is not None and record.key != key:
|
|
187
|
+
record = None
|
|
188
|
+
if record is None or fresh:
|
|
189
|
+
notify(f"building {view} (imports your code in a sandbox; this can take a while)")
|
|
190
|
+
fingerprint = tracked_fingerprint(cache, config)
|
|
191
|
+
# Checked here as well as in the builder: the view is signed when written, and a signed
|
|
192
|
+
# view is served without another check.
|
|
193
|
+
record = cache.write_view(
|
|
194
|
+
view, protocol.check(build(), schema), fingerprint, key, sign=True
|
|
195
|
+
)
|
|
196
|
+
cache.flush()
|
|
197
|
+
return Served(record, 0, True)
|
|
198
|
+
return Served(record, stale_files(cache, config, record), False)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: codeterrain
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: terrain: answers about your code in under a second. Local, LLM-free structure facts for coding agents; Python first.
|
|
5
|
+
Project-URL: Homepage, https://github.com/benchli-sports/codeterrain
|
|
6
|
+
Project-URL: Issues, https://github.com/benchli-sports/codeterrain/issues
|
|
7
|
+
Author: Mitbolas GmbH
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: NOTICE
|
|
11
|
+
Keywords: code-index,coding-agents,fastapi,fixtures,pytest,static-analysis
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Framework :: Pytest
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: pluggy>=1.3
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: boto3; extra == 'dev'
|
|
27
|
+
Requires-Dist: codeterrain-terraform; extra == 'dev'
|
|
28
|
+
Requires-Dist: codeterrain-typescript; extra == 'dev'
|
|
29
|
+
Requires-Dist: fastapi>=0.115; extra == 'dev'
|
|
30
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
33
|
+
Provides-Extra: terraform
|
|
34
|
+
Requires-Dist: codeterrain-terraform>=0.2; extra == 'terraform'
|
|
35
|
+
Provides-Extra: typescript
|
|
36
|
+
Requires-Dist: codeterrain-typescript>=0.1; extra == 'typescript'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
<img src="https://raw.githubusercontent.com/benchli-sports/codeterrain/main/docs/img/logo.svg" alt="codeterrain logo" width="96">
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<h1 align="center">codeterrain</h1>
|
|
44
|
+
|
|
45
|
+
<p align="center">
|
|
46
|
+
<b><code>terrain</code>: exact answers about your code, in under a second.</b><br>
|
|
47
|
+
For coding agents, and the people working beside them.
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
<p align="center">
|
|
51
|
+
Local only · no LLM · no network calls of its own · Python, pytest, FastAPI · JavaScript and TypeScript front ends · Terraform
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
<p align="center">
|
|
55
|
+
<a href="#quick-start">Quick start</a> ·
|
|
56
|
+
<a href="#what-it-answers">What it answers</a> ·
|
|
57
|
+
<a href="#results">Results</a> ·
|
|
58
|
+
<a href="#inferred-token-savings">Token savings</a> ·
|
|
59
|
+
<a href="#how-it-works">How it works</a> ·
|
|
60
|
+
<a href="#documentation">Docs</a>
|
|
61
|
+
</p>
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
A coding agent spends most of its reading on structure it has to rediscover every session: which
|
|
66
|
+
fixtures a test really gets, where a route is handled, who calls a function, which tests would catch a
|
|
67
|
+
change to it. The answers are in the code, but finding them takes rounds of grep and file reads, and a
|
|
68
|
+
wrong guess costs a review round.
|
|
69
|
+
|
|
70
|
+
`terrain` answers those questions from a cache, the way the framework itself would answer them.
|
|
71
|
+
|
|
72
|
+
| | |
|
|
73
|
+
|:---|:---|
|
|
74
|
+
| **Exact** | Setup order equals `pytest --setup-plan` on every node tested. `refs` follows imports and re-exports, so an alias counts and a same-named function elsewhere does not. |
|
|
75
|
+
| **Fast** | Most warm answers take 0.3 to 0.5 s on a large backend, Python start-up included. |
|
|
76
|
+
| **Cheaper to read** | 4.4x to 8.8x fewer characters than the grep-and-read it replaces, on code bases with a real fixture graph. |
|
|
77
|
+
| **Safe to point at a repo** | The static tier never runs the target's code. The tier that has to import it runs in a sandbox with no credentials, and by default it cannot write into the repository. |
|
|
78
|
+
|
|
79
|
+
## Quick start
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
uv tool install <path-to-this-checkout> # Python 3.12+; the only dependency is pluggy
|
|
83
|
+
# or "<path-to-this-checkout>[terraform]" to add the Terraform language pack, "[typescript]"
|
|
84
|
+
# to read front-end calls with tree-sitter, "[terraform,typescript]" for both
|
|
85
|
+
cd your-repo
|
|
86
|
+
terrain doctor # what it found, and which config it used
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Ask about a function: where it is called, how each call passes its arguments, and which tests patch it.
|
|
90
|
+
This is `terrain` run on its own source:
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
$ terrain refs is_test_module
|
|
94
|
+
function codeterrain.fixtures_static.is_test_module(rel) src/codeterrain/fixtures_static.py:327
|
|
95
|
+
6 calls (6 in code, 0 in tests), 0 with a shape problem, 0 used as a value, 0 patched
|
|
96
|
+
file:line in shape note
|
|
97
|
+
src/codeterrain/code_static.py:1349 update 1 positional
|
|
98
|
+
src/codeterrain/code_static.py:1357 update.<listcomp> 1 positional
|
|
99
|
+
src/codeterrain/fixtures_static.py:322 unlisted_test_trees.<genexpr> 1 positional
|
|
100
|
+
src/codeterrain/fixtures_static.py:435 build 1 positional
|
|
101
|
+
src/codeterrain/impact.py:272 _Fixtures.__init__ 1 positional
|
|
102
|
+
src/codeterrain/impact.py:424 _inherited_tests 1 positional
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Ask which tests would catch a change to it, through its callers and the fixtures that call them
|
|
106
|
+
(abridged):
|
|
107
|
+
|
|
108
|
+
```console
|
|
109
|
+
$ terrain impact is_test_module --depth 2
|
|
110
|
+
17 tests reach codeterrain.fixtures_static.is_test_module (0 directly), through 15 callers within 2 levels; 2 tests patch it …
|
|
111
|
+
test depth how via
|
|
112
|
+
tests/test_cli.py::test_a_handler_is_matched_by_… 2 call at tests/test_cli.py:1186 …update <- …is_test_module
|
|
113
|
+
tests/test_cli.py::test_a_route_text_that_matches_… 2 call at tests/test_cli.py:1142 …update <- …is_test_module
|
|
114
|
+
…
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Ask what a fixture is and what depends on it (from the sample repository in `tests/sample_repo`):
|
|
118
|
+
|
|
119
|
+
```console
|
|
120
|
+
$ terrain fixture db
|
|
121
|
+
name: db
|
|
122
|
+
scope: session
|
|
123
|
+
autouse: no
|
|
124
|
+
params: none
|
|
125
|
+
deps: _env_session
|
|
126
|
+
defined: tests/conftest.py:9
|
|
127
|
+
overrides: -
|
|
128
|
+
|
|
129
|
+
dependents (4):
|
|
130
|
+
fixture _reset_state tests/conftest.py:19 arg
|
|
131
|
+
fixture client tests/conftest.py:34 arg
|
|
132
|
+
fixture mod_cache tests/conftest.py:14 arg
|
|
133
|
+
…
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The commands that import your application (`setup-order`, `routes`) run in the **target's**
|
|
137
|
+
interpreter: pass `--python .venv/bin/python` or set `CODETERRAIN_PYTHON`. Add `--json` to any command
|
|
138
|
+
for the full data.
|
|
139
|
+
|
|
140
|
+
## What it answers
|
|
141
|
+
|
|
142
|
+
| Question | Command | What grep can't tell you |
|
|
143
|
+
|:---|:---|:---|
|
|
144
|
+
| Where is this defined? | `terrain def NAME` | Resolves a re-export to the real definition |
|
|
145
|
+
| Who calls it, passes it, patches it? | `terrain refs NAME` | Follows imports and aliases, flags calls that don't fit the signature, and separates tests that patch it out |
|
|
146
|
+
| Who imports this module, and what does it import? | `terrain importers MODULE`, `terrain imports MODULE` | Every import of it or of a name from it, relative imports resolved |
|
|
147
|
+
| Which tests would catch a change? | `terrain impact NAME` | Walks callers and fixtures the way pytest resolves them |
|
|
148
|
+
| Which fixtures does a test get, in what order? | `terrain setup-order NODEID` | pytest's own resolution, overrides and scopes included |
|
|
149
|
+
| What fixtures exist, and who uses one? | `terrain fixtures`, `terrain fixture NAME` | Which definition wins where, and every dependent |
|
|
150
|
+
| Where is a route handled, with what access? | `terrain routes`, `terrain route METHOD PATH` | Prefixes from router calls in other files, joined to your access manifest |
|
|
151
|
+
| Which HTTP calls does a front end make? | `terrain calls` | Paths built from templates, constants and concatenation |
|
|
152
|
+
| Which backend route does each call hit? | `terrain callers` | Front-end calls matched to backend routes across repositories |
|
|
153
|
+
| Where is a Terraform resource, variable or module, and who reads it? | `terrain def NAME`, `terrain refs NAME` (Terraform pack) | Terraform's own names (`aws_s3_bucket.logs`, `var.region`, `module.vpc`) and the block each reference sits in |
|
|
154
|
+
| What is the module tree, and which providers are pinned? | `terrain def --kind module`, `terrain def --kind required_provider` | Every module call with its source and version; provider aliases and what selects them |
|
|
155
|
+
|
|
156
|
+
Every option is in **[docs/commands.md](docs/commands.md)**.
|
|
157
|
+
|
|
158
|
+
## Results
|
|
159
|
+
|
|
160
|
+
Measured read-only on real code bases: a large FastAPI backend with a very large test suite, three
|
|
161
|
+
smaller Python code bases, an analytics project with a large fixture graph, and five front ends.
|
|
162
|
+
|
|
163
|
+
<table>
|
|
164
|
+
<tr>
|
|
165
|
+
<td align="center" width="25%"><h2>11 / 11</h2>test nodes where setup order equals <code>pytest --setup-plan</code></td>
|
|
166
|
+
<td align="center" width="25%"><h2>0.35 s</h2>median warm <code>fixtures</code> answer on the large backend</td>
|
|
167
|
+
<td align="center" width="25%"><h2>up to 8.8x</h2>fewer characters for an agent to read</td>
|
|
168
|
+
<td align="center" width="25%"><h2>0</h2>answers marked wrong in agents' logged lookups</td>
|
|
169
|
+
</tr>
|
|
170
|
+
</table>
|
|
171
|
+
|
|
172
|
+
<picture>
|
|
173
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/benchli-sports/codeterrain/main/docs/img/accuracy-dark.svg">
|
|
174
|
+
<img src="https://raw.githubusercontent.com/benchli-sports/codeterrain/main/docs/img/accuracy-light.svg" alt="Accuracy: setup order equal to pytest on 11 of 11 nodes; every route joined to the access manifest; 97% and 96% of front-end calls matched to a route; 99% of calls found by refs against grep in a monorepo" width="760">
|
|
175
|
+
</picture>
|
|
176
|
+
|
|
177
|
+
<picture>
|
|
178
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/benchli-sports/codeterrain/main/docs/img/speed-dark.svg">
|
|
179
|
+
<img src="https://raw.githubusercontent.com/benchli-sports/codeterrain/main/docs/img/speed-light.svg" alt="Warm answers on the backend, Python start-up included: fixtures 0.35 s, routes 0.35 s, refs 0.36 s, def 0.45 s, setup-order 0.49 s, impact 1.17 s, against a goal of under 1 s" width="760">
|
|
180
|
+
</picture>
|
|
181
|
+
|
|
182
|
+
`impact` is the one answer over a second: it walks callers and fixtures, and on the backend a heavily
|
|
183
|
+
tested function is reached by about a thousand tests. A cold build costs seconds for the static index
|
|
184
|
+
and about a minute and a half for the routes. After that, answers come from the cache, and a view is
|
|
185
|
+
stamped stale when files change.
|
|
186
|
+
|
|
187
|
+
<picture>
|
|
188
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/benchli-sports/codeterrain/main/docs/img/tokens-dark.svg">
|
|
189
|
+
<img src="https://raw.githubusercontent.com/benchli-sports/codeterrain/main/docs/img/tokens-light.svg" alt="Characters an agent reads, indexed to today's grep-and-read at 100: backend autouse fixtures 23, a route's handler and access 14, who uses the main fixture 67; analytics who uses the main fixture 11, fixtures of a test 29; a small suite's fixtures of a test 125" width="760">
|
|
190
|
+
</picture>
|
|
191
|
+
|
|
192
|
+
**The saving scales with the code.** Where the fixture graph is large, an answer is a small fraction
|
|
193
|
+
of the grep-and-read it replaces. In a small suite with a couple of fixtures the answer can be longer
|
|
194
|
+
than the grep; there the gain is the exact order, which grep can't show.
|
|
195
|
+
|
|
196
|
+
### In the field
|
|
197
|
+
|
|
198
|
+
Coding agents working on real tasks logged each `terrain` lookup: what they asked, whether the answer
|
|
199
|
+
was right, and what they would have done instead.
|
|
200
|
+
|
|
201
|
+
<picture>
|
|
202
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/benchli-sports/codeterrain/main/docs/img/field-dark.svg">
|
|
203
|
+
<img src="https://raw.githubusercontent.com/benchli-sports/codeterrain/main/docs/img/field-light.svg" alt="Outcomes of logged lookups: answered 79%, partly answered 12%, no help 2%, error 7% (mostly the wrong command), wrong answer 0%" width="760">
|
|
204
|
+
</picture>
|
|
205
|
+
|
|
206
|
+
- Each fully answered lookup saved the agent **about 1.3 tool calls**, by its own count.
|
|
207
|
+
- **No answer was marked wrong.** The partial answers came from output cut by the token budget, a cache
|
|
208
|
+
built from a different checkout than the one being edited, and a value read through a module alias.
|
|
209
|
+
- Most errors were the wrong command: `callers`, which is for routes, where `refs` was meant.
|
|
210
|
+
|
|
211
|
+
## Inferred token savings
|
|
212
|
+
|
|
213
|
+
An estimate from the two measurements above, not an A/B experiment:
|
|
214
|
+
|
|
215
|
+
| Step | Figure |
|
|
216
|
+
|:---|---:|
|
|
217
|
+
| Reading saved on a question `terrain` answers, large backend (4.4x to 6.9x fewer characters) | 77% to 86% |
|
|
218
|
+
| Lookups answered fully, in the field | 79% |
|
|
219
|
+
| Lookups answered partly (counted at half the saving) | 12% |
|
|
220
|
+
| **Inferred share of lookup reading saved on a large Python backend** | **about 65% to 73%** |
|
|
221
|
+
|
|
222
|
+
Lookups are only part of a session: reasoning, editing and test runs are untouched, and small code
|
|
223
|
+
bases save little. Tokens here are characters divided by four. A session-level A/B, the same lane run
|
|
224
|
+
with and without `terrain`, is the next measurement. The method and the per-repository results are in
|
|
225
|
+
[docs/why-codeterrain.md](docs/why-codeterrain.md).
|
|
226
|
+
|
|
227
|
+
## How it works
|
|
228
|
+
|
|
229
|
+
```mermaid
|
|
230
|
+
flowchart LR
|
|
231
|
+
ask(["agent or you: terrain refs NAME"]) --> cache[("cache per repository,<br/>keyed on content")]
|
|
232
|
+
cache -->|warm| answer(["answer in under a second"])
|
|
233
|
+
subgraph static ["Static tier: reads files, never runs them"]
|
|
234
|
+
fixtures["fixtures and tests<br/>(ast)"]
|
|
235
|
+
code["code index<br/>(ast, SQLite)"]
|
|
236
|
+
calls["front-end HTTP calls<br/>(tree-sitter, or the JS/TS tokenizer)"]
|
|
237
|
+
end
|
|
238
|
+
subgraph sandbox ["Import tier: sandboxed subprocess"]
|
|
239
|
+
order["setup order<br/>(pytest --collect-only)"]
|
|
240
|
+
routes["routes<br/>(imports the apps)"]
|
|
241
|
+
end
|
|
242
|
+
static -->|incremental, changed files only| cache
|
|
243
|
+
sandbox -->|on refresh; served stamped stale| cache
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- **The static tier** reads source with Python's `ast`, and front-end files with tree-sitter when the
|
|
247
|
+
TypeScript pack is installed (chosen for speed and accuracy) or a tokenizer written in Python when
|
|
248
|
+
it is not. It never imports or runs the target, and re-reads only the files that changed.
|
|
249
|
+
- **The import tier** needs the real framework: pytest's fixture resolution, FastAPI's route table. It
|
|
250
|
+
runs in a subprocess using the target's own interpreter, with an allowlisted environment, dummy cloud
|
|
251
|
+
credentials, no proxy and a best-effort network guard.
|
|
252
|
+
- **The cache** lives outside the checkout, in the git common directory or your user cache, and is
|
|
253
|
+
keyed on content hashes.
|
|
254
|
+
|
|
255
|
+
More in [docs/how-it-works.md](docs/how-it-works.md), [docs/sandbox.md](docs/sandbox.md) and
|
|
256
|
+
[SECURITY.md](SECURITY.md).
|
|
257
|
+
|
|
258
|
+
## Why this exists
|
|
259
|
+
|
|
260
|
+
codeterrain started inside [benchli](https://www.benchli.com), the team sports platform built by Mitbolas GmbH. Its Python backend
|
|
261
|
+
is a large FastAPI service with a very large test suite, developed with coding agents. Most of the
|
|
262
|
+
agents' tokens went on re-mapping the same structure every session, and a hand-kept map went stale
|
|
263
|
+
within weeks. The goal became exact answers, straight from the code, locally and in under a second, in
|
|
264
|
+
every repository benchli and Mitbolas develop with agents. The full story, and why we did not adopt a
|
|
265
|
+
knowledge-graph tool, are in [docs/why-codeterrain.md](docs/why-codeterrain.md).
|
|
266
|
+
|
|
267
|
+
## Documentation
|
|
268
|
+
|
|
269
|
+
| | |
|
|
270
|
+
|:---|:---|
|
|
271
|
+
| [Commands](docs/commands.md) | Every command, option and output |
|
|
272
|
+
| [Configuration](docs/configuration.md) | Precedence, every key with its default, monorepos and non-default layouts |
|
|
273
|
+
| [How it works](docs/how-it-works.md) | The two tiers, the cache, extractor programs |
|
|
274
|
+
| [The sandbox](docs/sandbox.md) | What the import tier runs with, and every gap in the network guard |
|
|
275
|
+
| [Limits](docs/limits.md) | What a static walk does not see, and the bounds set on purpose |
|
|
276
|
+
| [Security](SECURITY.md) | The trust model: what is guaranteed and what is best-effort |
|
|
277
|
+
| [Extractor protocol](docs/extractor-protocol.md) | Writing an extractor or a language pack for another language or framework |
|
|
278
|
+
| [Terraform pack](packages/codeterrain-terraform/README.md) | `.tf` files in `def` and `refs`: resources, modules, providers |
|
|
279
|
+
| [TypeScript pack](packages/codeterrain-typescript/README.md) | Front-end calls read with tree-sitter, and why it is the reader to install |
|
|
280
|
+
| [Design notes](docs/design-v0.2.md) | The decisions behind the front-end calls and the language packs |
|
|
281
|
+
| [Why codeterrain](docs/why-codeterrain.md) | The origin, the measurements, what is not proven yet |
|
|
282
|
+
| [Changelog](CHANGELOG.md) | What changed, release by release |
|
|
283
|
+
|
|
284
|
+
## Development
|
|
285
|
+
|
|
286
|
+
```sh
|
|
287
|
+
uv run --extra dev ruff check .
|
|
288
|
+
uv run --extra dev pytest
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Run pytest as a single process (no `-n`). The suite spawns real pytest and interpreter subprocesses, and
|
|
292
|
+
a few tests skip on Windows (no `sendmsg`, no unix-domain sockets). CI also runs it against FastAPI below
|
|
293
|
+
and above 0.137 and pytest 8 and 9. Contributor and review rules are in [AGENTS.md](AGENTS.md).
|
|
294
|
+
|
|
295
|
+
Copyright Mitbolas GmbH. Licensed under Apache-2.0 (see LICENSE and NOTICE).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
codeterrain/__init__.py,sha256=kbKUI36tipRdN7g4B0DICDspO7zQzN00si_SOs-OynE,120
|
|
2
|
+
codeterrain/_guard.py,sha256=32Umgb6BlZ7EXbJeu80DPy3iyFk2anZYyVBEAJMzuFE,8026
|
|
3
|
+
codeterrain/_pytest_dump_plugin.py,sha256=8oTsoTpFjPeYt5nvFhfq0jW1WvpPtOk2fbrENqHJQhg,9607
|
|
4
|
+
codeterrain/_routes_runner.py,sha256=GdRpB21qZiJvaSN4IWAYiPI_cPoy897DY_AAvJ6zRAY,4329
|
|
5
|
+
codeterrain/budget.py,sha256=YH3BqK1sl8GpOedX9-SG4yiaNwc7s6YqXM9G7ok5xgM,4920
|
|
6
|
+
codeterrain/cache.py,sha256=36D-Wlksqkao-flEbaN4bxyTmAY_75yp-6h40uf9Hs8,23889
|
|
7
|
+
codeterrain/calls_join.py,sha256=EltfnJpnboLCQRNvhfaHwW8yDJFewu_5QwQKEOPfuAY,17663
|
|
8
|
+
codeterrain/calls_static.py,sha256=HzQeG8APcBmf5ty66DSUw4l3GuihJiK2QmTRd5syM1A,44511
|
|
9
|
+
codeterrain/cli.py,sha256=_xXMFVjmuk3zQCO6h3dNEDBgVm6w4CskYNqudA7rzRI,56195
|
|
10
|
+
codeterrain/code_static.py,sha256=p7GkMtI9Bb8wkli3whoS9fzrxo90u2gjLlK0R6Etolo,114453
|
|
11
|
+
codeterrain/config.py,sha256=SopiPUPVA6owkWwYlNGe_Urkrn0icHdjGPpflCH9RC8,14138
|
|
12
|
+
codeterrain/executables.py,sha256=xBKuXZCMHetN8bF9wOPCDfNM3e1p-AhgnRxMj5vuIZg,5747
|
|
13
|
+
codeterrain/fixtures_order.py,sha256=N3BxufI2htGgvcuQhuQCWDyiy2KRUtU71jwdJgTriHM,2987
|
|
14
|
+
codeterrain/fixtures_static.py,sha256=0OzaqYJjOyYW0EnOWxzKqmJc0uVysoG8rSoeKu6tkeo,19779
|
|
15
|
+
codeterrain/hookspecs.py,sha256=gxceVjWniIXrUr3cizY-dQqCuWNUl5Atyl7zsAvgBeA,797
|
|
16
|
+
codeterrain/impact.py,sha256=JZO_UxCnF4ONvh2ZRoUe9iDnu6jcghTGXv7aEXbxdrY,36386
|
|
17
|
+
codeterrain/languages.py,sha256=cB_jmHYM35qzhjoskNkqVfaLkedC-8FWNh-hIsL7LEs,12403
|
|
18
|
+
codeterrain/protocol.py,sha256=0wE4N5X9YSsH1KY-Bhl1iUgh7VmOJ0L7qonHjyZQjk8,5199
|
|
19
|
+
codeterrain/routes_view.py,sha256=Bdq1xm_HpOZzvAmf1A1BqBS614SItgrF8LsTqhtZ8hs,2606
|
|
20
|
+
codeterrain/sandbox.py,sha256=P4yygdmTajD_Q5cGka0HMNi10WO_T1ZgvF0tnrKyH-s,22108
|
|
21
|
+
codeterrain/views.py,sha256=CRphKKZB8z26xBzeutdQ_StwDAsaw922a4nEGqI16EU,7996
|
|
22
|
+
codeterrain/plugins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
codeterrain/plugins/fastapi_routes.py,sha256=pMeCz_zttIBEYnbbFbMIMk0o1s3JP9aHN_HnEuhjC3w,7227
|
|
24
|
+
codeterrain/plugins/manifest_join.py,sha256=si4VZQ-07GrQzh320sBkrrwGzaZ1chQOz2eqsrAG-Y4,4233
|
|
25
|
+
codeterrain-0.3.0.dist-info/METADATA,sha256=m3L-A7T0IF8Dk5_K01P7uwNtXdixtHK_yagzAikfSIQ,16626
|
|
26
|
+
codeterrain-0.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
27
|
+
codeterrain-0.3.0.dist-info/entry_points.txt,sha256=QaavLQ6DTRRFzhtLN4lmrspr_rLPcSBtsC3xwPtx9Mo,49
|
|
28
|
+
codeterrain-0.3.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
29
|
+
codeterrain-0.3.0.dist-info/licenses/NOTICE,sha256=MT49ddjoHgOc0m_mlK0QtmaYMthIRnrZJYzRFmCldFg,120
|
|
30
|
+
codeterrain-0.3.0.dist-info/RECORD,,
|