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/SKILL.md +87 -0
- codecalc/__init__.py +3 -0
- codecalc/__main__.py +19 -0
- codecalc/_worker_bootstrap.py +211 -0
- codecalc/bin/codecalc-exec.exe +0 -0
- codecalc/complexity.py +162 -0
- codecalc/contract.py +624 -0
- codecalc/doctor.py +428 -0
- codecalc/errors.py +214 -0
- codecalc/exact.py +969 -0
- codecalc/execution_service.py +302 -0
- codecalc/executor.py +1324 -0
- codecalc/guarded.py +300 -0
- codecalc/landlock.py +231 -0
- codecalc/logic.py +500 -0
- codecalc/mcp_middleware.py +135 -0
- codecalc/optimization.py +296 -0
- codecalc/optional.py +107 -0
- codecalc/packages.py +360 -0
- codecalc/parsing.py +266 -0
- codecalc/provider_adapters/__init__.py +1 -0
- codecalc/provider_adapters/piston.py +34 -0
- codecalc/providers.py +802 -0
- codecalc/py.typed +0 -0
- codecalc/registry.py +254 -0
- codecalc/run_supervisor.py +178 -0
- codecalc/runtimes.py +542 -0
- codecalc/safe_expr.py +346 -0
- codecalc/server.py +1103 -0
- codecalc/sessions.py +1060 -0
- codecalc/strict_runtime.py +232 -0
- codecalc/tools.py +326 -0
- codecalc/translation.py +286 -0
- codecalc/units.py +410 -0
- codecalc-0.1.0.dist-info/METADATA +838 -0
- codecalc-0.1.0.dist-info/RECORD +39 -0
- codecalc-0.1.0.dist-info/WHEEL +4 -0
- codecalc-0.1.0.dist-info/entry_points.txt +2 -0
- codecalc-0.1.0.dist-info/licenses/LICENSE +201 -0
codecalc/SKILL.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: codecalc
|
|
3
|
+
description: Use when about to state a number, claim two programs are equivalent, claim a speedup, or state a complexity — codecalc computes these exactly instead of estimating them, and this file says when calling is mandatory and how results must be reported.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# codecalc
|
|
7
|
+
|
|
8
|
+
You are a language model. You are good at writing code and bad at arithmetic,
|
|
9
|
+
and the failure is not that you are unsure — it is that you are confident. This
|
|
10
|
+
file exists because "call a tool when you are uncertain" is worthless advice to
|
|
11
|
+
a model that feels certain about `0.1 + 0.2`.
|
|
12
|
+
|
|
13
|
+
So the rules below key on the SITUATION, never on how sure you feel.
|
|
14
|
+
|
|
15
|
+
## Call these — mandatory, no exceptions
|
|
16
|
+
|
|
17
|
+
Not a preference. If the situation matches, the call happens before you write
|
|
18
|
+
the number.
|
|
19
|
+
|
|
20
|
+
| Situation | Tool |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Any **non-integer** operand or result | `calc_exact` |
|
|
23
|
+
| Any **comparison** whose verdict you will state | `compare_threshold` |
|
|
24
|
+
| Integers past **2^53** (9007199254740992) | `calc_exact`, and `float_repr` if a float is involved |
|
|
25
|
+
| Percentages, ratios, shares | `percentage` |
|
|
26
|
+
| Unit, byte, duration or epoch conversion | `convert_units`, `data_sizes`, `human_duration`, `epoch_time` |
|
|
27
|
+
| Any number that appears in your answer **as a claim** rather than an illustration | whichever of the above fits |
|
|
28
|
+
| You ported code A → B and are about to call it a port | `verify_translation` |
|
|
29
|
+
| You are about to state that a rewrite is **faster** | `verify_optimization` |
|
|
30
|
+
| You are about to say two languages **behave the same** | `compare_edge_cases` |
|
|
31
|
+
| You are about to state a **Big-O** | `analyze_complexity` (inferred) or `benchmark` (measured) — and say which |
|
|
32
|
+
|
|
33
|
+
**2^53 is not academic.** `float_repr(9007199254740993)` reports
|
|
34
|
+
`stored: "9007199254740992"`. The value changed and nothing raised. That is the
|
|
35
|
+
boundary, and `int_widths` will not show it to you — that tool reports machine
|
|
36
|
+
integer widths (i8…u64), which is a different question.
|
|
37
|
+
|
|
38
|
+
## Do not call these — no justification needed
|
|
39
|
+
|
|
40
|
+
Calling a tool for these is noise, and a rule that fires constantly is a rule
|
|
41
|
+
someone turns off:
|
|
42
|
+
|
|
43
|
+
- small-integer intermediate arithmetic — `2 + 3 + 4` needs no tool
|
|
44
|
+
- order-of-magnitude estimates you label as estimates
|
|
45
|
+
- arithmetic inside prose that nobody will act on
|
|
46
|
+
- `execute_code` to confirm syntax you can read
|
|
47
|
+
|
|
48
|
+
Operand count is **not** the test. `0.1 + 0.2` has two operands and is the
|
|
49
|
+
canonical failure; `2 + 3 + 4` has three and is never wrong. Type predicts
|
|
50
|
+
error. Length does not.
|
|
51
|
+
|
|
52
|
+
## Report what came back — mandatory, no exceptions
|
|
53
|
+
|
|
54
|
+
This is the second-order failure: call the tool correctly, then misreport it.
|
|
55
|
+
Every field below exists because the result would otherwise imply something
|
|
56
|
+
untrue, and each was added after that implication bit someone.
|
|
57
|
+
|
|
58
|
+
| Field | What you must say |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `passed: true` | State `total`. "Equivalent on 7 inputs" — never "verified equivalent" |
|
|
61
|
+
| `inconclusive > 0` | Surface it. Not a pass, not a failure, and the caller is entitled to know which they got |
|
|
62
|
+
| `unenforced: [...]` non-empty | The guarantee you are about to describe **did not hold**. Name what was not applied |
|
|
63
|
+
| `backend: "python"` | Weaker sandbox. `no_net` was not applied and `peak_memory_kb` is `None` |
|
|
64
|
+
| `analysis: "regex-fallback"` | The structure was **guessed**, not parsed |
|
|
65
|
+
| `method: "static-estimate"` | Nobody measured this. `benchmark` reports `method: "empirical"` |
|
|
66
|
+
| `output_error` present | `stdout`/`stderr` are **not** what the program produced |
|
|
67
|
+
| `ok: false` | Say what failed. The `error` field is written to be quoted |
|
|
68
|
+
|
|
69
|
+
**Never drop a field you do not understand. Quote it.**
|
|
70
|
+
|
|
71
|
+
That rule is not for careless models. In one session an agent verifying this
|
|
72
|
+
very codebase — carefully, with the source open — made three probe errors in a
|
|
73
|
+
row: reversed arguments that made a nested loop report `O(1)`, a read of a
|
|
74
|
+
`divergences` key that does not exist (it is `mismatched`), and the same
|
|
75
|
+
wrong-key mistake on `algebraic_equiv` (it is `identical`). Each was caught only
|
|
76
|
+
by printing the whole result instead of the field it expected. Confidence about
|
|
77
|
+
a result's shape is exactly as unreliable as confidence about arithmetic.
|
|
78
|
+
|
|
79
|
+
## What this tool set will not do for you
|
|
80
|
+
|
|
81
|
+
- It does not check that a package or function **exists**. A fabricated API is
|
|
82
|
+
still your error; `execute_code` with an import is the closest available check.
|
|
83
|
+
- `verify_translation` proves equivalence **on the inputs it ran**, which are
|
|
84
|
+
`DEFAULT_EDGE_INPUTS` unless you pass your own. It is evidence, not proof.
|
|
85
|
+
Pass inputs that would distinguish your port if it were wrong.
|
|
86
|
+
- `analyze_complexity` reads structure. It cannot see work hidden inside a
|
|
87
|
+
library call.
|
codecalc/__init__.py
ADDED
codecalc/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""`python -m codecalc` — the same entry point as the `codecalc` script.
|
|
2
|
+
|
|
3
|
+
`[project.scripts]` already gives `codecalc` a console script pointing at
|
|
4
|
+
`server:main`, so `uvx codecalc` and `pipx run codecalc` have always worked.
|
|
5
|
+
What did not work was `python -m codecalc`, which needs this file and failed
|
|
6
|
+
with "No module named codecalc.__main__" — the form people reach for when a
|
|
7
|
+
package is installed but its script directory is not on PATH, which is the
|
|
8
|
+
normal state inside a venv someone has not activated.
|
|
9
|
+
|
|
10
|
+
Deliberately thin. Anything that lives here is invisible to the console-script
|
|
11
|
+
path and would be a second way for the two to diverge.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .server import main
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Stateful python3 REPL worker: JSON-lines request/response over a channel
|
|
2
|
+
executed code cannot reach.
|
|
3
|
+
|
|
4
|
+
Three things here are deliberate and were all bugs before.
|
|
5
|
+
|
|
6
|
+
**The protocol does not share a file descriptor with the executed code.** The
|
|
7
|
+
first version swapped `sys.stdout` for a StringIO and wrote responses with
|
|
8
|
+
`print()`. That is a Python-level rebind: a subprocess inherits **fd 1** and
|
|
9
|
+
writes straight past it. So an entirely ordinary session line —
|
|
10
|
+
|
|
11
|
+
subprocess.run(["echo", "hi"])
|
|
12
|
+
|
|
13
|
+
— put raw bytes into the response stream. At startup fd 1 is duplicated to a
|
|
14
|
+
private descriptor that only this module writes to, and fd 1/2 are then pointed
|
|
15
|
+
at capture files. Nothing the executed code can do reaches the protocol channel,
|
|
16
|
+
and as a bonus subprocess output is now CAPTURED rather than lost.
|
|
17
|
+
|
|
18
|
+
**On Windows, the fd-1 dup above is not trusted, and a file stands in for it.**
|
|
19
|
+
`os.dup2` remaps this process's CRT file-descriptor table, but a subprocess
|
|
20
|
+
started by executed code without an explicit stdout override can still be
|
|
21
|
+
handed the OS-level standard HANDLE instead — a separate piece of state the
|
|
22
|
+
CRT does not reliably keep in sync with the fd table on Windows. When
|
|
23
|
+
`CODECALC_PROTO_PATH` is set (Windows, or `_FORCE_FILE_PROTOCOL` in
|
|
24
|
+
sessions.py forcing the same route on POSIX for test coverage), responses are
|
|
25
|
+
appended to that file instead of written to the fd-1 dup, exactly mirroring
|
|
26
|
+
the node worker's Windows route (`sessions.py`'s `_TailReader`). The per-call
|
|
27
|
+
fds are additionally mirrored onto the OS-level standard handles there (see
|
|
28
|
+
`_set_std_handle` below), so a child that queries the raw handle rather than
|
|
29
|
+
the CRT fd still lands in the capture file, not wherever fd 1 used to point.
|
|
30
|
+
|
|
31
|
+
**Every response carries the id of the request it answers.** Corruption used to
|
|
32
|
+
be unrecoverable *and silent*: the reader consumed the junk, left the real reply
|
|
33
|
+
queued, and every later call returned the PREVIOUS call's result with
|
|
34
|
+
`ok: True`. Measured — a call asking for `marker-4` came back with the output of
|
|
35
|
+
the call before it. An echoed id makes a desync detectable instead of trusted;
|
|
36
|
+
see `Worker.run`, which kills the worker rather than return a stale answer.
|
|
37
|
+
|
|
38
|
+
Output is capped here too. The sandboxed path caps at 64 KiB and reports OLE;
|
|
39
|
+
this path returned a 20 MB string whole, built three times over in memory on the
|
|
40
|
+
way out through JSON.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import contextlib
|
|
44
|
+
import io
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import sys
|
|
48
|
+
import tempfile
|
|
49
|
+
import traceback
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
#: Matches MAX_OUTPUT_BYTES in the Rust executor. A session is not exempt from
|
|
53
|
+
#: the output cap just because it is stateful.
|
|
54
|
+
MAX_OUTPUT_BYTES = 64 * 1024
|
|
55
|
+
|
|
56
|
+
# The protocol channel. Two routes:
|
|
57
|
+
#
|
|
58
|
+
# CODECALC_PROTO_PATH set responses are appended to that file. Set on
|
|
59
|
+
# Windows (and by sessions.py's
|
|
60
|
+
# _FORCE_FILE_PROTOCOL, on POSIX, for test
|
|
61
|
+
# coverage) because the fd-1 dup below is not
|
|
62
|
+
# trustworthy there.
|
|
63
|
+
# unset (default, POSIX) a dup of the ORIGINAL stdout, taken before
|
|
64
|
+
# anything is redirected. Executed code cannot
|
|
65
|
+
# name this descriptor, and closing fd 1 (which
|
|
66
|
+
# code is free to do) does not touch it.
|
|
67
|
+
_proto_path = os.environ.get("CODECALC_PROTO_PATH", "")
|
|
68
|
+
_proto_path_obj = Path(_proto_path) if _proto_path else None
|
|
69
|
+
if _proto_path:
|
|
70
|
+
_proto = None
|
|
71
|
+
else:
|
|
72
|
+
_proto_fd = os.dup(1)
|
|
73
|
+
_proto = os.fdopen(_proto_fd, "w", encoding="utf-8", newline="\n")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _write_proto(line: str) -> None:
|
|
77
|
+
if _proto_path_obj is not None:
|
|
78
|
+
# Append, not write: a fresh open() per response so the file descriptor
|
|
79
|
+
# itself is never held across the exec() below, where executed code
|
|
80
|
+
# could otherwise inherit or close it.
|
|
81
|
+
with _proto_path_obj.open("a", encoding="utf-8", newline="\n") as fh:
|
|
82
|
+
fh.write(line)
|
|
83
|
+
else:
|
|
84
|
+
_proto.write(line)
|
|
85
|
+
_proto.flush()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# On Windows, mirror the per-call fd 1/2 redirection onto the OS-level
|
|
89
|
+
# standard handles too. `os.dup2` only remaps this process's CRT
|
|
90
|
+
# file-descriptor table; a child spawned by executed code without an explicit
|
|
91
|
+
# stdout override can still be handed GetStdHandle()'s value instead, which
|
|
92
|
+
# the CRT does not reliably keep pointed at the same place. Elsewhere this is
|
|
93
|
+
# a no-op: the fd-1 dup2 is already sufficient on POSIX.
|
|
94
|
+
_STD_OUTPUT_HANDLE = -11
|
|
95
|
+
_STD_ERROR_HANDLE = -12
|
|
96
|
+
|
|
97
|
+
if os.name == "nt":
|
|
98
|
+
import ctypes
|
|
99
|
+
import msvcrt
|
|
100
|
+
|
|
101
|
+
_kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
102
|
+
|
|
103
|
+
def _set_std_handle(std_id: int, fd: int) -> int:
|
|
104
|
+
"""Point the OS-level standard handle at fd's handle; return the old one."""
|
|
105
|
+
prev = _kernel32.GetStdHandle(std_id)
|
|
106
|
+
_kernel32.SetStdHandle(std_id, msvcrt.get_osfhandle(fd))
|
|
107
|
+
return prev
|
|
108
|
+
|
|
109
|
+
def _restore_std_handle(std_id: int, handle: int) -> None:
|
|
110
|
+
_kernel32.SetStdHandle(std_id, handle)
|
|
111
|
+
else:
|
|
112
|
+
def _set_std_handle(std_id: int, fd: int) -> int:
|
|
113
|
+
return -1
|
|
114
|
+
|
|
115
|
+
def _restore_std_handle(std_id: int, handle: int) -> None:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Requests still arrive on stdin. The iterator below binds to this object once,
|
|
120
|
+
# so rebinding sys.stdin per call cannot disturb it.
|
|
121
|
+
_requests = sys.stdin
|
|
122
|
+
|
|
123
|
+
ns = {"__name__": "__main__", "__builtins__": __builtins__}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_capped(fh) -> tuple[str, bool]:
|
|
127
|
+
"""Read a capture file, capped, reporting whether anything was dropped."""
|
|
128
|
+
fh.seek(0)
|
|
129
|
+
data = fh.read(MAX_OUTPUT_BYTES + 1)
|
|
130
|
+
truncated = len(data) > MAX_OUTPUT_BYTES
|
|
131
|
+
if truncated:
|
|
132
|
+
data = data[:MAX_OUTPUT_BYTES]
|
|
133
|
+
return data.decode("utf-8", errors="replace") + ("\n...[truncated]" if truncated else ""), truncated
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _respond(payload: dict) -> None:
|
|
137
|
+
_write_proto(json.dumps(payload) + "\n")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
for _line in _requests:
|
|
141
|
+
_line = _line.strip()
|
|
142
|
+
if not _line:
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
req = json.loads(_line)
|
|
146
|
+
except Exception:
|
|
147
|
+
continue
|
|
148
|
+
code = req.get("code", "")
|
|
149
|
+
stdin_data = req.get("stdin", "")
|
|
150
|
+
req_id = req.get("id")
|
|
151
|
+
|
|
152
|
+
# Not a `with` block: these files have to outlive the try/finally that
|
|
153
|
+
# restores fds 1 and 2, and they are read after the restore. Closed
|
|
154
|
+
# explicitly below.
|
|
155
|
+
out_f = tempfile.TemporaryFile() # noqa: SIM115
|
|
156
|
+
err_f = tempfile.TemporaryFile() # noqa: SIM115
|
|
157
|
+
# Save fds 1/2 so they can be restored even if the executed code closes them.
|
|
158
|
+
saved_out, saved_err = os.dup(1), os.dup(2)
|
|
159
|
+
prev_stdin, prev_stdout, prev_stderr = sys.stdin, sys.stdout, sys.stderr
|
|
160
|
+
saved_std_out_handle = saved_std_err_handle = None
|
|
161
|
+
try:
|
|
162
|
+
os.dup2(out_f.fileno(), 1)
|
|
163
|
+
os.dup2(err_f.fileno(), 2)
|
|
164
|
+
# See the module docstring: on Windows the CRT fd redirection above
|
|
165
|
+
# does not, by itself, reach a child spawned without an explicit
|
|
166
|
+
# stdout/stderr override. Mirroring it onto the OS-level standard
|
|
167
|
+
# handles is what makes THAT child land in the capture files too.
|
|
168
|
+
saved_std_out_handle = _set_std_handle(_STD_OUTPUT_HANDLE, 1)
|
|
169
|
+
saved_std_err_handle = _set_std_handle(_STD_ERROR_HANDLE, 2)
|
|
170
|
+
# Python-level streams over the SAME descriptors, so interpreter output
|
|
171
|
+
# and subprocess output interleave in the order they were written.
|
|
172
|
+
sys.stdout = io.TextIOWrapper(os.fdopen(os.dup(1), "wb"), encoding="utf-8",
|
|
173
|
+
errors="replace", write_through=True)
|
|
174
|
+
sys.stderr = io.TextIOWrapper(os.fdopen(os.dup(2), "wb"), encoding="utf-8",
|
|
175
|
+
errors="replace", write_through=True)
|
|
176
|
+
sys.stdin = io.StringIO(stdin_data)
|
|
177
|
+
with contextlib.redirect_stdout(sys.stdout), contextlib.redirect_stderr(sys.stderr):
|
|
178
|
+
exec(compile(code, "<session>", "exec"), ns)
|
|
179
|
+
ok, err = True, ""
|
|
180
|
+
except BaseException:
|
|
181
|
+
# BaseException, not Exception: sys.exit() in a session line must end
|
|
182
|
+
# that line, not the worker.
|
|
183
|
+
ok, err = False, traceback.format_exc()
|
|
184
|
+
finally:
|
|
185
|
+
for stream in (sys.stdout, sys.stderr):
|
|
186
|
+
with contextlib.suppress(Exception):
|
|
187
|
+
stream.flush()
|
|
188
|
+
if saved_std_out_handle is not None:
|
|
189
|
+
_restore_std_handle(_STD_OUTPUT_HANDLE, saved_std_out_handle)
|
|
190
|
+
if saved_std_err_handle is not None:
|
|
191
|
+
_restore_std_handle(_STD_ERROR_HANDLE, saved_std_err_handle)
|
|
192
|
+
os.dup2(saved_out, 1)
|
|
193
|
+
os.dup2(saved_err, 2)
|
|
194
|
+
os.close(saved_out)
|
|
195
|
+
os.close(saved_err)
|
|
196
|
+
sys.stdin, sys.stdout, sys.stderr = prev_stdin, prev_stdout, prev_stderr
|
|
197
|
+
|
|
198
|
+
stdout_s, out_trunc = _read_capped(out_f)
|
|
199
|
+
stderr_s, err_trunc = _read_capped(err_f)
|
|
200
|
+
out_f.close()
|
|
201
|
+
err_f.close()
|
|
202
|
+
|
|
203
|
+
_respond({
|
|
204
|
+
"id": req_id,
|
|
205
|
+
"ok": ok,
|
|
206
|
+
"stdout": stdout_s,
|
|
207
|
+
"stderr": stderr_s or err,
|
|
208
|
+
"exit_code": 0 if ok else 1,
|
|
209
|
+
"output_truncated": out_trunc or err_trunc,
|
|
210
|
+
"verdict": "OLE" if (out_trunc or err_trunc) else ("OK" if ok else "RTE"),
|
|
211
|
+
})
|
|
Binary file
|
codecalc/complexity.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Complexity analyzer: static heuristic Big-O estimation.
|
|
2
|
+
|
|
3
|
+
Language-agnostic structural scan:
|
|
4
|
+
- loop constructs per nesting depth -> O(n^k) base
|
|
5
|
+
- recursion detection -> exponential / nlogn flags
|
|
6
|
+
- linear-scan builtins (sort etc.) -> n log n adjustments
|
|
7
|
+
- hash/constant-time ops -> n^1 stays n
|
|
8
|
+
|
|
9
|
+
Structural only: a tree-sitter parse plus growth heuristics. There is no LLM
|
|
10
|
+
in this path and no network call. An optional LLM "second opinion" used to sit
|
|
11
|
+
behind CODECALC_COMPLEXITY_LLM; it is gone, because a static analyser that can
|
|
12
|
+
make a network call is a different kind of tool, and the deterministic estimate
|
|
13
|
+
was always the product.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from . import parsing
|
|
21
|
+
|
|
22
|
+
LOOP_RE = re.compile(
|
|
23
|
+
r"\b(for|while|foreach|until|loop|repeat)\b"
|
|
24
|
+
r"|\bfor\s*\(" # C-style for(
|
|
25
|
+
r"|\.forEach\(|\.map\(|\.filter\(|\.reduce\(" # JS iterators
|
|
26
|
+
r"|for\s+\w+\s+in\b" # python/ruby for x in
|
|
27
|
+
r"|for\s+\w+\s*:=" # go for x :=
|
|
28
|
+
r"|for\s+\w+\s+of\b" # JS for x of
|
|
29
|
+
)
|
|
30
|
+
RECURSION_RE = re.compile(
|
|
31
|
+
r"\b(def|func(?:tion)?|fn|sub)\s+(\w+)\s*\("
|
|
32
|
+
r"|public\s+(?:static\s+)?\w+\s+(\w+)\s*\("
|
|
33
|
+
)
|
|
34
|
+
CALL_RE = re.compile(r"\b(\w+)\s*\(")
|
|
35
|
+
SORT_RE = re.compile(r"\.sort\(|\.sort_by\(|\bsort\(|\bsorted\(|\bsort_by\b")
|
|
36
|
+
BINARY_RE = re.compile(r"\bbinary_search|bisect|\bmid\s*=\s*\(|while\s+\w+\s*<=\s*\w+.*(?:mid|lo|hi)")
|
|
37
|
+
HASH_RE = re.compile(r"\bdict\b|\bHashMap\b|\bMap\b|\bHashSet\b|\bSet\b|\b{}|\bHash\b")
|
|
38
|
+
NESTED_RE = re.compile(r"^\s+") # indentation marker
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _count_loops(code: str) -> tuple[int, int]:
|
|
42
|
+
"""Returns (loop_count, max_nesting_depth) via indentation-aware scan."""
|
|
43
|
+
lines = code.splitlines()
|
|
44
|
+
indent_stack: list[int] = []
|
|
45
|
+
loops = 0
|
|
46
|
+
max_depth = 0
|
|
47
|
+
for raw in lines:
|
|
48
|
+
if not raw.strip() or raw.strip().startswith(("#", "//", "/*", "*")):
|
|
49
|
+
continue
|
|
50
|
+
indent = len(raw) - len(raw.lstrip())
|
|
51
|
+
while indent_stack and indent <= indent_stack[-1]:
|
|
52
|
+
indent_stack.pop()
|
|
53
|
+
if LOOP_RE.search(raw) and not raw.strip().startswith(("}", ")")):
|
|
54
|
+
loops += 1
|
|
55
|
+
indent_stack.append(indent)
|
|
56
|
+
max_depth = max(max_depth, len(indent_stack))
|
|
57
|
+
return loops, max_depth
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _detect_recursion(code: str) -> tuple[bool, str]:
|
|
61
|
+
"""Best-effort recursion detection: does any function call itself?"""
|
|
62
|
+
funcs = []
|
|
63
|
+
for m in RECURSION_RE.finditer(code):
|
|
64
|
+
name = m.group(2) or m.group(3)
|
|
65
|
+
if name:
|
|
66
|
+
funcs.append(name)
|
|
67
|
+
for fn in funcs:
|
|
68
|
+
# find the function body and look for self-calls
|
|
69
|
+
m = re.search(rf"\b{fn}\s*\([^)]*\)[^#\n]*\n((?:.|\n)*?)(?=\n\S|\Z)", code)
|
|
70
|
+
if m and re.search(rf"\b{fn}\s*\(", m.group(1)):
|
|
71
|
+
return True, fn
|
|
72
|
+
return False, ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def analyze(code: str, language: str = "python3") -> dict:
|
|
76
|
+
"""Estimate asymptotic complexity from the code's STRUCTURE.
|
|
77
|
+
|
|
78
|
+
Loop counting and recursion detection come from a real parser
|
|
79
|
+
(`codecalc.parsing`). They used to come from regexes, which counted the word
|
|
80
|
+
"for" inside strings, comments and identifiers like `format()`, read a
|
|
81
|
+
chained `.map().filter().reduce()` as three loops, and called a docstring
|
|
82
|
+
mentioning a function's own name "recursion". tests/test_parsing_vs_regex.py
|
|
83
|
+
holds the divergent cases with the correct answer for each.
|
|
84
|
+
|
|
85
|
+
`analysis` in the result says which path produced the numbers, so a caller
|
|
86
|
+
can tell a parse from a fallback instead of assuming.
|
|
87
|
+
"""
|
|
88
|
+
facts = parsing.analyse(code, language)
|
|
89
|
+
if facts.parsed:
|
|
90
|
+
loops, depth = facts.loops, facts.max_loop_depth
|
|
91
|
+
recursive = bool(facts.recursive_functions)
|
|
92
|
+
fn = facts.recursive_functions[0] if recursive else ""
|
|
93
|
+
analysis = "tree-sitter"
|
|
94
|
+
else:
|
|
95
|
+
# No grammar for this language, or the parser rejected the input. Say so
|
|
96
|
+
# rather than reporting a heuristic as though it were a parse.
|
|
97
|
+
loops, depth = _count_loops(code)
|
|
98
|
+
recursive, fn = _detect_recursion(code)
|
|
99
|
+
analysis = "regex-fallback"
|
|
100
|
+
|
|
101
|
+
# These two stay textual: "does this call a sort" and "does this use a hash
|
|
102
|
+
# map" are library questions, not grammar ones, and a parser has no more
|
|
103
|
+
# authority over them than a regex does.
|
|
104
|
+
has_sort = bool(SORT_RE.search(code))
|
|
105
|
+
has_hash = bool(HASH_RE.search(code))
|
|
106
|
+
|
|
107
|
+
# base estimate
|
|
108
|
+
if recursive:
|
|
109
|
+
estimate = "O(2^n) — recursion detected"
|
|
110
|
+
basis = f"recursive function '{fn}'"
|
|
111
|
+
elif depth >= 2:
|
|
112
|
+
estimate = f"O(n^{depth})"
|
|
113
|
+
basis = f"{depth} nested loop levels ({loops} loop constructs)"
|
|
114
|
+
elif loops == 1:
|
|
115
|
+
estimate = "O(n) — with sort: O(n log n)"
|
|
116
|
+
basis = "1 loop level"
|
|
117
|
+
elif has_sort and loops == 0:
|
|
118
|
+
estimate = "O(n log n)"
|
|
119
|
+
basis = "sorting call, no loops"
|
|
120
|
+
else:
|
|
121
|
+
estimate = "O(1)"
|
|
122
|
+
basis = "no loops, no recursion"
|
|
123
|
+
|
|
124
|
+
notes = []
|
|
125
|
+
if has_sort:
|
|
126
|
+
notes.append("sort/builtin detected (O(n log n) worst case)")
|
|
127
|
+
if has_hash:
|
|
128
|
+
notes.append("hash structures → average-case O(1) lookups")
|
|
129
|
+
if loops == 0 and not recursive and not has_sort:
|
|
130
|
+
notes.append("constant-time surface; may hide loops in library calls")
|
|
131
|
+
|
|
132
|
+
if analysis == "regex-fallback" and facts.reason:
|
|
133
|
+
notes.append(f"structural heuristic only ({facts.reason})")
|
|
134
|
+
if facts.parsed and facts.has_error:
|
|
135
|
+
notes.append("source has syntax errors; the parse is partial")
|
|
136
|
+
|
|
137
|
+
result = {
|
|
138
|
+
"ok": True,
|
|
139
|
+
"estimate": estimate,
|
|
140
|
+
"basis": basis,
|
|
141
|
+
"loops": loops,
|
|
142
|
+
"max_nesting": depth,
|
|
143
|
+
"recursion": recursive,
|
|
144
|
+
# TWO ORTHOGONAL FACTS, and only one of them was reported before.
|
|
145
|
+
# `analysis` says how the source was READ — parsed by tree-sitter, or
|
|
146
|
+
# guessed by regex when no grammar was available. `method` says whether
|
|
147
|
+
# the answer was MEASURED at all, and it never was: this counts loops
|
|
148
|
+
# and detects recursion. A caller could previously see
|
|
149
|
+
# analysis="tree-sitter" and reasonably hear "we determined the
|
|
150
|
+
# complexity", when the truth is "we read the structure accurately and
|
|
151
|
+
# inferred from it". `benchmark` is the empirical counterpart and
|
|
152
|
+
# reports method="empirical".
|
|
153
|
+
"method": "static-estimate",
|
|
154
|
+
"analysis": analysis,
|
|
155
|
+
"grammar": facts.grammar,
|
|
156
|
+
"functions": facts.functions if facts.parsed else [],
|
|
157
|
+
"notes": notes,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|