codecalc 0.1.0__py3-none-win_amd64.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.
codecalc/doctor.py ADDED
@@ -0,0 +1,428 @@
1
+ """What this install actually resolved, as data (THE-780).
2
+
3
+ `codecalc doctor` existed and printed prose. Prose is right for the operator
4
+ reading it and useless to the installer script, the CI job or the agent that
5
+ wants to branch on whether the native executor is present — all of which had to
6
+ either parse English or make a tool call and read a field out of the result.
7
+
8
+ So the report is built here as a dict and rendered by the caller. Two
9
+ consequences worth having: the text and the JSON cannot disagree, because there
10
+ is one source; and the report is testable without spawning a CLI, which is what
11
+ made the degraded-environment cases (no binary, no runtimes, unwritable
12
+ workspace) reachable at all.
13
+
14
+ THE FOUR-STATE RUNTIME VOCABULARY, AND WHY IT IS NOT FOUR STATES BY DEFAULT
15
+
16
+ THE-780 asks doctor to distinguish `supported`, `installed`, `available` and
17
+ `unhealthy`. The honest version of that distinction costs something:
18
+
19
+ supported codecalc knows this language; nothing for it resolves here
20
+ installed its command resolves on the sandbox PATH and is executable
21
+ unhealthy its command resolves and is NOT executable, or was run and failed
22
+ available it was actually RUN here and answered
23
+
24
+ `available` is the only one that requires executing the runtime, and there are
25
+ 31 of them. Doing that on every `doctor` invocation would turn a diagnostic
26
+ into a minute-long build step, so the default reports resolution only —
27
+ `installed`, never `available` — and `--deep` is what promotes a resolved
28
+ runtime to `available` or demotes it to `unhealthy` by running it.
29
+
30
+ That split is the point rather than a shortcut. Reporting `available` for a
31
+ binary this process only found on PATH would be the exact defect this project
32
+ keeps correcting: a field that claims a stronger measurement than was taken.
33
+ `status_basis` says which of the two ran, per report, so a reader never has to
34
+ infer it.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import os
40
+ import shutil
41
+ import subprocess
42
+ import sys
43
+ import tempfile
44
+ from pathlib import Path
45
+
46
+ from . import __version__, contract, executor, landlock, optional, providers, registry
47
+
48
+ #: The four states, ordered weakest to strongest claim. Defined in registry.py
49
+ #: and re-exported here: `list_languages` reports the same vocabulary now
50
+ #: (THE-817), and registry is the module all three readers already import.
51
+ #: contract.py's schema still imports it from this name.
52
+ RUNTIME_STATES = registry.RUNTIME_STATES
53
+
54
+ #: Extras, and the modules whose presence defines them. Mirrors what the text
55
+ #: renderer used to hardcode.
56
+ EXTRAS: dict[str, tuple[str, ...]] = {
57
+ "symbolic": ("sympy", "z3"),
58
+ "parsing": ("tree_sitter_language_pack",),
59
+ }
60
+
61
+ #: Registry entries that are a second spelling of a language already counted.
62
+ #: Mirrors `_ALIAS_ENTRIES` in server.py and `ALIAS_ENTRIES` in
63
+ #: scripts/check_claims.py — the count this reports has to agree with the
64
+ #: README's and the gate's, or it is a fourth opinion on a number this repo
65
+ #: already has a gate for.
66
+ ALIAS_ENTRIES = frozenset({"c++"})
67
+
68
+
69
+ def primary_command(entry: dict, name: str | None = None) -> str:
70
+ """The command whose presence decides whether a language resolves.
71
+
72
+ Mirrors the fallback in `executor.probe()` deliberately, and
73
+ `tests/test_doctor.py` asserts the two agree on every language. Two copies
74
+ of this rule that drift would make `doctor` and the executor disagree about
75
+ which runtimes exist, which is worse than either being wrong on its own.
76
+
77
+ For a shell-wrapped language the deciding command is the REAL tool, not
78
+ `bash` (THE-835): `bash` resolving says nothing about whether gleam is
79
+ installed, and probing it advertised wrapper languages on machines that
80
+ had never seen them. `name` is optional only for callers that predate the
81
+ wrapper distinction; passing it is what makes the answer honest.
82
+ """
83
+ if name is not None and name in registry.SHELL_WRAPPED:
84
+ return registry.WRAPPED_TOOL[name]
85
+ cmd = entry["run"][0] if entry["run"] else ""
86
+ if cmd.startswith("{"):
87
+ cmd = (entry["compile"] or ["bash"])[0] if entry["compile"] else "bash"
88
+ if cmd.startswith(("bash", "sh")):
89
+ cmd = "bash"
90
+ return cmd
91
+
92
+
93
+ def _runtime_status(cmd: str) -> tuple[str, str | None]:
94
+ """`(status, resolved_path)` from resolution alone — no execution."""
95
+ if not cmd:
96
+ return "supported", None
97
+ runtime_path = registry.runtime_path()
98
+ path = shutil.which(cmd, path=runtime_path)
99
+ if path is not None:
100
+ return "installed", path
101
+
102
+ # `shutil.which` already filters by the execute bit, so a present-but-not-
103
+ # executable command comes back as None and would report `supported` —
104
+ # "codecalc knows this language, nothing for it here" — when the file is
105
+ # sitting right there unable to run. That is the most confusing answer
106
+ # available: the operator can see the binary and doctor says it is absent.
107
+ #
108
+ # The first version of this function had exactly that bug. It called
109
+ # os.access(X_OK) on which()'s result, which can only ever be True, so
110
+ # `unhealthy` was unreachable without --deep and the four-state vocabulary
111
+ # was three states wearing four names. Caught by the test written for it.
112
+ #
113
+ # So the fallback scan is a SECOND pass, deliberately after which(): which()
114
+ # handles PATHEXT on Windows and this does not, and getting that wrong would
115
+ # trade a rare state for a common platform.
116
+ for directory in runtime_path.split(os.pathsep):
117
+ if not directory:
118
+ continue
119
+ candidate = Path(directory) / cmd
120
+ if candidate.is_file():
121
+ return "unhealthy", str(candidate)
122
+ return "supported", None
123
+
124
+
125
+ def _grammar_cache() -> dict:
126
+ """Is the tree-sitter grammar cache warm? (THE-821)
127
+
128
+ `analyze_complexity` parses with tree-sitter, and the grammars are NOT in
129
+ the wheel — `tree-sitter-language-pack` ships a ~5 MB extension and fetches
130
+ each grammar on first use, in-process, into a local cache (28 grammars,
131
+ 89 MB, ~15s cold).
132
+
133
+ That is a socket opened from inside the server, which is a surprise on a
134
+ host chosen for being offline. Reported here so it is discoverable BEFORE a
135
+ tool call degrades to `regex-fallback`, rather than after — an operator
136
+ planning an air-gapped install otherwise has no way to find out.
137
+
138
+ `cached: false` is not an error. It means the first analysis of each
139
+ language will reach the network; `scripts/prefetch_grammars.py` warms it.
140
+ """
141
+ if not optional.have("tree_sitter_language_pack"):
142
+ return {"extra_installed": False, "cached": False, "path": None,
143
+ "grammars": 0,
144
+ "detail": "the 'parsing' extra is not installed; "
145
+ "analyze_complexity uses the regex fallback"}
146
+ try:
147
+ from tree_sitter_language_pack import cache_dir as _cache_dir
148
+
149
+ path = str(_cache_dir())
150
+ except Exception as exc:
151
+ return {"extra_installed": True, "cached": False, "path": None,
152
+ "grammars": 0,
153
+ "detail": f"cannot resolve the grammar cache: "
154
+ f"{type(exc).__name__}: {exc}"}
155
+
156
+ # COUNTED, not just existence-checked: an empty cache directory is created
157
+ # by the pack before anything is downloaded, so `path.is_dir()` is true on a
158
+ # completely cold host and would report a warm cache that is not there.
159
+ count = 0
160
+ p = Path(path)
161
+ if p.is_dir():
162
+ count = sum(1 for f in p.iterdir()
163
+ if f.is_file() and f.suffix in (".so", ".dylib", ".dll", ".pyd"))
164
+ return {
165
+ "extra_installed": True,
166
+ "cached": count > 0,
167
+ "path": path,
168
+ "grammars": count,
169
+ "detail": None if count else (
170
+ "no grammars cached — the first analyze_complexity call per "
171
+ "language will DOWNLOAD one. Run scripts/prefetch_grammars.py to "
172
+ "warm it, which is what an offline install needs"),
173
+ }
174
+
175
+
176
+ def _workspace_check() -> dict:
177
+ """Can we actually create a workdir? Written, not assumed.
178
+
179
+ `tempfile.gettempdir()` returning a path says nothing about whether this
180
+ process may write there — a read-only TMPDIR, a full disk and a correct
181
+ setup all return the same string. Every execution needs a writable workdir,
182
+ so this is the one check whose failure means nothing will work.
183
+ """
184
+ root = tempfile.gettempdir()
185
+ try:
186
+ with tempfile.TemporaryDirectory(prefix="codecalc-doctor-") as d:
187
+ probe = Path(d) / "w"
188
+ probe.write_text("ok", encoding="utf-8")
189
+ probe.read_text(encoding="utf-8")
190
+ return {"path": root, "writable": True, "error": None}
191
+ except OSError as exc:
192
+ return {"path": root, "writable": False,
193
+ "error": f"{type(exc).__name__}: {exc}"}
194
+
195
+
196
+ def report(deep: bool = False) -> dict:
197
+ """Everything `doctor` knows, as data.
198
+
199
+ `deep=True` executes each resolved runtime to promote it to `available` or
200
+ demote it to `unhealthy`. Off by default: 31 runtimes is not a diagnostic,
201
+ it is a build.
202
+ """
203
+ backend = executor.backend()
204
+ abi = landlock.abi_version()
205
+
206
+ extras = []
207
+ for name, modules in EXTRAS.items():
208
+ missing = [m for m in modules if not optional.have(m)]
209
+ extras.append({
210
+ "name": name,
211
+ "installed": not missing,
212
+ "missing": missing,
213
+ # The exact command, because "install the extra" is not actionable
214
+ # and this is the one place that knows the distribution name.
215
+ "remedy": None if not missing else f"pip install 'codecalc[{name}]'",
216
+ })
217
+
218
+ langs = [name for name in sorted(registry.LANGUAGES) if name not in ALIAS_ENTRIES]
219
+ runtimes = []
220
+ for name in langs:
221
+ cmd = primary_command(registry.LANGUAGES[name], name)
222
+ # A plan the platform cannot run reports `supported` — "codecalc knows
223
+ # this language, nothing FOR IT here" — with the reason in `detail`,
224
+ # BEFORE any resolution happens. Resolving the tool first and then
225
+ # deciding would report gleam `installed` on a Windows box where the
226
+ # plan is structurally unable to run (THE-835): a stronger claim than
227
+ # was measured, which is the defect this file's docstring is about.
228
+ if not registry.plan_supported(name, windows=os.name == "nt"):
229
+ runtimes.append({
230
+ "name": name, "command": cmd, "status": "supported",
231
+ "path": None, "version": None,
232
+ "detail": "the canonical plan needs a POSIX shell to scaffold "
233
+ "a project; unsupported on Windows",
234
+ })
235
+ continue
236
+ status, path = _runtime_status(cmd)
237
+ # A wrapped plan needs its shell AS WELL as its tool. The tool
238
+ # resolving while bash is absent (minimal containers) is still a plan
239
+ # that cannot run, and `installed` would overstate it.
240
+ if (name in registry.SHELL_WRAPPED and status == "installed"
241
+ and shutil.which("bash", path=registry.runtime_path()) is None):
242
+ status, path = "supported", None
243
+ # `version` is present on every row so a caller never has to branch on
244
+ # the key's existence, and is None unless it was actually read. Under
245
+ # --deep only, for the same reason `available` is: asking 31 runtimes
246
+ # their version is a build, not a diagnostic.
247
+ row = {"name": name, "command": cmd, "status": status, "path": path,
248
+ "version": _runtime_version(cmd, path) if deep else None}
249
+ # Only languages with a hello program are promoted. Running the others
250
+ # with an EMPTY source would execute a no-op, find no "codecalc" in its
251
+ # stdout, and demote a perfectly good runtime to `unhealthy` — a --deep
252
+ # run reporting failures it manufactured itself. They stay `installed`,
253
+ # which is what was actually measured about them.
254
+ if deep and status == "installed" and name in _HELLO:
255
+ probe = executor.execute(name, _HELLO[name], timeout=20)
256
+ ran = probe.get("ok") is True and "codecalc" in (probe.get("stdout") or "")
257
+ row["status"] = "available" if ran else "unhealthy"
258
+ if not ran:
259
+ row["detail"] = (probe.get("error")
260
+ or (probe.get("stderr") or "")[:200]
261
+ or f"verdict={probe.get('verdict')}")
262
+ runtimes.append(row)
263
+
264
+ summary = {state: sum(1 for r in runtimes if r["status"] == state)
265
+ for state in RUNTIME_STATES}
266
+ workspace = _workspace_check()
267
+ provider_registry = providers.configured_registry()
268
+ execution_providers = []
269
+ for descriptor in provider_registry.descriptors():
270
+ row = dict(descriptor)
271
+ if descriptor["host_class"] in {"local", providers.strict_host_platform()}:
272
+ health = provider_registry.select(descriptor["provider_id"]).health()
273
+ row["ready"] = health["ready"]
274
+ row["technology"] = health.get("technology")
275
+ row["detail"] = health.get("error")
276
+ else:
277
+ # Doctor must not turn into an unsolicited remote network probe.
278
+ row["ready"] = None
279
+ row["technology"] = None
280
+ row["detail"] = "remote readiness not probed by doctor"
281
+ execution_providers.append(row)
282
+
283
+ # `healthy` is deliberately NARROW. A missing optional extra and an
284
+ # uninstalled Haskell are ordinary facts about a host, not faults, and
285
+ # exiting non-zero for them would make `doctor` useless as an install check
286
+ # — the thing THE-780 wants it to be. What makes an install unhealthy is
287
+ # that it cannot execute anything: no workspace to run in, or no backend.
288
+ healthy = bool(workspace["writable"]) and backend in ("rust", "python")
289
+
290
+ return {
291
+ # Under the result contract's version and policy rather than a third
292
+ # number of its own. This is machine-readable output a client programs
293
+ # against, which is what that policy is for; a separate doctor version
294
+ # would mean two documents to keep in step and two policies to read.
295
+ "contract_version": contract.CONTRACT_VERSION,
296
+ "codecalc_version": __version__,
297
+ "healthy": healthy,
298
+ "python": {"version": sys.version.split()[0], "platform": sys.platform},
299
+ "backend": {
300
+ "kind": backend,
301
+ "binary": executor._rust,
302
+ # Named rather than left as an absence: "no binary" and "a binary
303
+ # that would not run" are different problems with different fixes.
304
+ "detail": None if backend == "rust" else (
305
+ "native executor not found — running the pure-Python fallback, "
306
+ "which cannot enforce no_net. Set CODECALC_REQUIRE_NATIVE=1 to "
307
+ "make this a startup failure instead of a weaker sandbox."),
308
+ },
309
+ "execution_providers": execution_providers,
310
+ "install_sandbox": {
311
+ "landlock_abi": abi,
312
+ "confined": bool(abi),
313
+ "detail": None if abi else "installs are not confined on this host",
314
+ },
315
+ "extras": extras,
316
+ # Where analyze_complexity's grammars come from, and whether they are
317
+ # here yet. Not an extra and not a runtime: it is a network dependency
318
+ # of a tool, which neither of those blocks describes.
319
+ "grammar_cache": _grammar_cache(),
320
+ # Which measurement produced the statuses above. Without this a reader
321
+ # cannot tell `installed` ("found on PATH") from a --deep run that
322
+ # simply found nothing runnable.
323
+ "status_basis": "executed" if deep else "resolved",
324
+ "runtimes": runtimes,
325
+ "runtime_summary": summary,
326
+ "workspace": workspace,
327
+ "skill_file": _skill_path(),
328
+ "remedies": _remedies(backend, extras, runtimes, workspace),
329
+ }
330
+
331
+
332
+ #: A one-line program per language would be 31 more things to keep correct, and
333
+ #: `--deep` only needs to prove the runtime answers at all. Languages absent
334
+ #: here are still resolved and reported; they are simply not promoted.
335
+ _HELLO = {
336
+ "python3": 'print("codecalc")',
337
+ "node": 'console.log("codecalc")',
338
+ "ruby": 'puts "codecalc"',
339
+ "perl": 'print "codecalc\\n";',
340
+ "php": '<?php echo "codecalc\\n";',
341
+ "lua": 'print("codecalc")',
342
+ "bash": 'echo codecalc',
343
+ }
344
+
345
+
346
+ #: How to ask a runtime its version. Keyed by COMMAND rather than by language,
347
+ #: because `primary_command` collapses several languages onto one binary and
348
+ #: asking `bash` its version four times would be three wasted spawns.
349
+ #:
350
+ #: `--version` is the default and the map holds only the exceptions, so a
351
+ #: runtime added later works without an entry. An exception is not a special
352
+ #: case for its own sake: `java -version` predates the GNU convention and
353
+ #: prints to STDERR, which is why both streams are read below.
354
+ _VERSION_FLAG: dict[str, str] = {
355
+ "java": "-version",
356
+ "kotlinc": "-version",
357
+ }
358
+
359
+ #: Commands with no version flag worth calling. `escript` and `tclsh` have no
360
+ #: non-interactive `--version`, and invoking them without one starts a REPL that
361
+ #: would hang until the timeout. Listed rather than discovered, because "it hung
362
+ #: for 10 seconds" is a bad way to learn this.
363
+ _NO_VERSION = frozenset({"escript", "tclsh"})
364
+
365
+
366
+ def _runtime_version(command: str, path: str | None) -> str | None:
367
+ """The runtime's own version string, or None when it could not be read.
368
+
369
+ NONE MEANS NOT MEASURED, NEVER "no version". THE-780 asks doctor to report
370
+ versions; it does not ask it to invent them. A runtime that has no version
371
+ flag, times out, crashes, or answers with something unparsable all produce
372
+ `None` here and leave `status` untouched — a version that could not be read
373
+ says nothing about whether the runtime works, and demoting it would report a
374
+ failure this function manufactured.
375
+
376
+ Runs under the executor's 23-entry env allowlist rather than the doctor
377
+ process's own environment. This is a diagnostic, but it is still spawning
378
+ host binaries, and there is no reason for `gcc --version` to see an API key.
379
+ """
380
+ if not path or command in _NO_VERSION:
381
+ return None
382
+ flag = _VERSION_FLAG.get(command, "--version")
383
+ try:
384
+ proc = subprocess.run(
385
+ [path, flag],
386
+ capture_output=True, timeout=10, env=executor._env(), check=False,
387
+ )
388
+ except (OSError, subprocess.SubprocessError):
389
+ return None
390
+ # stdout OR stderr: `java -version` uses stderr, and a runtime that answers
391
+ # on the stream we did not read is indistinguishable from one that did not
392
+ # answer at all.
393
+ for stream in (proc.stdout, proc.stderr):
394
+ text = (stream or b"").decode("utf-8", "replace").strip()
395
+ for line in text.splitlines():
396
+ line = line.strip()
397
+ if line:
398
+ # First non-empty line, capped. Some runtimes print a paragraph
399
+ # (gcc prints its licence), and the report is a diagnostic, not
400
+ # a transcript.
401
+ return line[:120]
402
+ return None
403
+
404
+
405
+ def _skill_path() -> str | None:
406
+ p = Path(__file__).resolve().parent / "SKILL.md"
407
+ return str(p) if p.is_file() else None
408
+
409
+
410
+ def _remedies(backend: str, extras: list, runtimes: list, workspace: dict) -> list[str]:
411
+ """What to do about it, in the order it matters."""
412
+ out = []
413
+ if not workspace["writable"]:
414
+ out.append(f"workspace {workspace['path']} is not writable "
415
+ f"({workspace['error']}) — nothing can execute until it is; "
416
+ f"set TMPDIR to a writable directory")
417
+ if backend != "rust":
418
+ out.append("build the native executor (`cargo build --release` in "
419
+ "executor/, then copy it to bin/) or install a platform "
420
+ "wheel — the fallback cannot enforce no_net")
421
+ for e in extras:
422
+ if e["remedy"]:
423
+ out.append(e["remedy"])
424
+ unhealthy = [r["name"] for r in runtimes if r["status"] == "unhealthy"]
425
+ if unhealthy:
426
+ out.append(f"resolved but not runnable: {', '.join(unhealthy)} — "
427
+ f"check the file mode and the runtime PATH")
428
+ return out
codecalc/errors.py ADDED
@@ -0,0 +1,214 @@
1
+ """Stable error codes for the result contract (THE-781).
2
+
3
+ Every failing tool result carried a free-form English sentence and nothing
4
+ else — 121 distinct `"error":` strings across the package. A caller could show
5
+ them to a human and could do nothing else with them: "unknown language 'foo'",
6
+ "expression too long", "uv not found" and "session worker died" are four
7
+ different situations that a program has to tell apart by matching prose that
8
+ nobody promised to keep stable.
9
+
10
+ So this adds a `code` beside the message rather than replacing it. The message
11
+ stays human-facing and free to improve; the code is the part a caller may
12
+ branch on, and the part this module promises not to churn.
13
+
14
+ TWO MECHANISMS, AND THE SECOND IS TRANSITIONAL
15
+ `classify()` maps an EXCEPTION to a code and is the mechanism worth having: it
16
+ keys on the exception type, which the code that raised it chose deliberately.
17
+
18
+ That was going to be enough. It is not. The first draft attached it only to
19
+ `guarded_call`, on the assumption that symbolic tools raise and it catches
20
+ them. Measured against the eight most reachable failures — a bad expression,
21
+ an unknown language, an unknown session, a blown recursion ceiling — it
22
+ reached ZERO of them, because those paths `return {"ok": False, ...}` directly
23
+ and never raise. An assumption about control flow, disproved by running it.
24
+
25
+ So `ensure_code()` is the second mechanism: it fills in a code for a failing
26
+ result that has none, by matching the message. Message matching is fragile —
27
+ improve a sentence and the classification changes with it — so a code derived
28
+ that way is marked `code_inferred: true` and is NOT the same claim as one
29
+ attached at the raise site.
30
+
31
+ The distinction is the point. `scripts/check_claims.py` counts inferred codes
32
+ and floors the total, so the transitional half is visible and shrinking rather
33
+ than quietly permanent.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ #: The taxonomy. Values are the wire format and are API: renaming one is a
39
+ #: breaking change to the result contract, which is the whole point of having
40
+ #: them. Deliberately coarse — eight buckets a caller can act on differently,
41
+ #: not a catalogue of every way a call can fail.
42
+ VALIDATION = "validation" # the request was malformed or out of range
43
+ RUNTIME_UNAVAILABLE = "runtime_unavailable" # a language/compiler is not installed here
44
+ TIMEOUT = "timeout" # a wall clock or CPU deadline expired
45
+ RESOURCE_EXHAUSTED = "resource_exhausted" # memory, output or process ceiling hit
46
+ PERMISSION_DENIED = "permission_denied" # refused by a jail, ACL or elevation gate
47
+ DEPENDENCY_MISSING = "dependency_missing" # an optional extra is not installed
48
+ WORKER_FAILURE = "worker_failure" # a session worker died or desynced
49
+ INTERNAL = "internal" # a defect here, not in the request
50
+
51
+ #: Every code, for the gate and for callers that want to validate.
52
+ ALL_CODES = frozenset({
53
+ VALIDATION, RUNTIME_UNAVAILABLE, TIMEOUT, RESOURCE_EXHAUSTED,
54
+ PERMISSION_DENIED, DEPENDENCY_MISSING, WORKER_FAILURE, INTERNAL,
55
+ })
56
+
57
+ #: What a caller can do about each, in one line. Returned by the MCP surface so
58
+ #: the remedy travels with the code instead of living only in documentation
59
+ #: somebody has to find.
60
+ REMEDIES = {
61
+ VALIDATION: "fix the arguments and retry; the message names the field",
62
+ RUNTIME_UNAVAILABLE: "install the runtime, or call list_languages to see what this host has",
63
+ TIMEOUT: "raise `timeout`, or reduce the work; a partial result is not returned",
64
+ RESOURCE_EXHAUSTED: "raise the relevant ceiling (max_memory_mb, max_output_kb) or reduce the work",
65
+ PERMISSION_DENIED: "the path or operation is outside what this server permits; it will not succeed on retry",
66
+ DEPENDENCY_MISSING: "install the named extra; the message gives the exact pip command",
67
+ WORKER_FAILURE: "the session is unusable — call session_stop and start a new one",
68
+ INTERNAL: "a defect in codecalc; the message is worth reporting verbatim",
69
+ }
70
+
71
+
72
+ def classify(exc: BaseException) -> str:
73
+ """Map an exception to a code. Pure, and exception-type first.
74
+
75
+ Ordering matters. `MissingExtra` subclasses `ImportError` on purpose (see
76
+ optional.py), so it has to be tested BEFORE the ImportError branch or every
77
+ absent extra reports as an internal defect — which is the opposite of
78
+ actionable, since the caller can fix a missing extra and cannot fix a bug.
79
+
80
+ Type before message, always. Matching on message text is how a classifier
81
+ becomes wrong silently when someone improves a sentence, so `_from_message`
82
+ below is the last resort and is only consulted for exception types that
83
+ carry no useful class.
84
+ """
85
+ from .optional import MissingExtra
86
+
87
+ if isinstance(exc, MissingExtra):
88
+ return DEPENDENCY_MISSING
89
+ if isinstance(exc, TimeoutError):
90
+ return TIMEOUT
91
+ if isinstance(exc, MemoryError):
92
+ return RESOURCE_EXHAUSTED
93
+ if isinstance(exc, RecursionError):
94
+ # Depth is a resource here: the guard exists so a deeply nested
95
+ # expression cannot take the process down, and the caller's remedy is
96
+ # to simplify the input, same as any other ceiling.
97
+ return RESOURCE_EXHAUSTED
98
+ if isinstance(exc, PermissionError):
99
+ return PERMISSION_DENIED
100
+ if isinstance(exc, FileNotFoundError):
101
+ # A missing executable is the common case on this path — a language
102
+ # whose runtime is not installed. A missing FILE is validation, but the
103
+ # executor reports that through its own result shape rather than here.
104
+ return RUNTIME_UNAVAILABLE
105
+ if isinstance(exc, ImportError):
106
+ return DEPENDENCY_MISSING
107
+ if isinstance(exc, (ValueError, TypeError, KeyError, IndexError, ArithmeticError)):
108
+ return VALIDATION
109
+ if isinstance(exc, OSError):
110
+ return INTERNAL
111
+ return _from_message(str(exc))
112
+
113
+
114
+ #: Message fragments consulted ONLY for exceptions with no informative type.
115
+ #: Ordered, first match wins, lowercase comparison.
116
+ #: The first version of this list had ten entries and classified four of the
117
+ #: seven most reachable failures as INTERNAL — including a bad expression and
118
+ #: an unknown session id. INTERNAL's remedy is "a defect in codecalc; the
119
+ #: message is worth reporting verbatim", so that told a caller who mistyped an
120
+ #: expression to go and file a bug. A wrong code is worse than a missing one,
121
+ #: because it is actionable in the wrong direction.
122
+ #:
123
+ #: INTERNAL is the fallback and should be RARE. If a common failure lands there,
124
+ #: the list is wrong, not the failure.
125
+ _MESSAGE_HINTS = (
126
+ # resource ceilings — before the parse hints, because "expression too long"
127
+ # is a ceiling and contains no parse vocabulary but sits near it
128
+ ("timed out", TIMEOUT),
129
+ ("timeout", TIMEOUT),
130
+ ("memory ceiling", RESOURCE_EXHAUSTED),
131
+ ("memory", RESOURCE_EXHAUSTED),
132
+ ("too long", RESOURCE_EXHAUSTED),
133
+ ("too deeply nested", RESOURCE_EXHAUSTED),
134
+ ("too many", RESOURCE_EXHAUSTED),
135
+ ("resource limit", RESOURCE_EXHAUSTED),
136
+ ("exceeded", RESOURCE_EXHAUSTED),
137
+ # dependency
138
+ ("not installed", DEPENDENCY_MISSING),
139
+ ("ships in the", DEPENDENCY_MISSING),
140
+ # permission / jail
141
+ ("permission", PERMISSION_DENIED),
142
+ ("escapes", PERMISSION_DENIED),
143
+ ("outside", PERMISSION_DENIED),
144
+ ("refus", PERMISSION_DENIED),
145
+ # worker / session lifecycle
146
+ ("worker", WORKER_FAILURE),
147
+ ("session is", WORKER_FAILURE),
148
+ ("desync", WORKER_FAILURE),
149
+ # runtime availability
150
+ ("runtime unavailable", RUNTIME_UNAVAILABLE),
151
+ ("not found in path", RUNTIME_UNAVAILABLE),
152
+ # validation — the largest bucket, and the one INTERNAL was stealing
153
+ ("unknown language", VALIDATION),
154
+ ("unknown session", VALIDATION),
155
+ ("no such file", VALIDATION),
156
+ ("no such directory", VALIDATION),
157
+ ("parse error", VALIDATION),
158
+ ("could not parse", VALIDATION),
159
+ ("could not be tokenised", VALIDATION),
160
+ ("sympify", VALIDATION),
161
+ ("unexpected character", VALIDATION),
162
+ ("invalid", VALIDATION),
163
+ ("must be", VALIDATION),
164
+ ("empty", VALIDATION),
165
+ )
166
+
167
+
168
+ def _from_message(message: str) -> str:
169
+ low = message.lower()
170
+ for needle, code in _MESSAGE_HINTS:
171
+ if needle in low:
172
+ return code
173
+ return INTERNAL
174
+
175
+
176
+ def error_result(code: str, message: str, **extra) -> dict:
177
+ """The failing half of the result contract.
178
+
179
+ `code` is validated rather than trusted: a typo'd code is worse than no
180
+ code, because a caller branching on it silently takes the wrong branch and
181
+ nothing raises. An unknown code becomes INTERNAL and says so in the
182
+ message, which is a loud failure at the point of the mistake.
183
+ """
184
+ if code not in ALL_CODES:
185
+ message = f"[unclassified code {code!r}] {message}"
186
+ code = INTERNAL
187
+ return {"ok": False, "code": code, "error": message,
188
+ "remedy": REMEDIES[code], **extra}
189
+
190
+
191
+ def ensure_code(result: dict) -> dict:
192
+ """Give a failing result a code if it has none. Mutates and returns it.
193
+
194
+ The transitional half. Applied where every tool result passes on its way
195
+ out, so the model-facing surface is covered without editing 121 return
196
+ sites — but the code comes from the MESSAGE, which is a weaker claim than
197
+ one chosen at the raise site, so it is labelled `code_inferred: true`.
198
+
199
+ A caller can therefore tell the two apart, and so can the gate: the goal is
200
+ for this function to have less to do over time, not to be the answer.
201
+
202
+ Only touches results that are failing and codeless. A successful result
203
+ gets nothing — a `code` on an `ok: True` payload would read as a warning
204
+ and there is no such thing here.
205
+ """
206
+ if not isinstance(result, dict) or result.get("ok") is not False:
207
+ return result
208
+ if result.get("code"):
209
+ return result
210
+ code = _from_message(str(result.get("error") or ""))
211
+ result["code"] = code
212
+ result["remedy"] = REMEDIES[code]
213
+ result["code_inferred"] = True
214
+ return result