dsh-context-guardian 0.1.0-alpha.10

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.
package/cg_doctor.py ADDED
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env python3
2
+ """cg_doctor.py -- check a context-guardian install and say how to fix what is wrong.
3
+
4
+ One line per check (OK / WARN / FAIL, each non-OK line followed by a `fix:` line), then a
5
+ count line; exit 1 when a check failed. Stdlib only, Python 3.10+, all three OSes. MIT.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+
15
+ # subprocess is used for exactly one thing: the bridge probe below, with an argv list.
16
+ import subprocess # nosec B404
17
+ import sys
18
+ import tempfile
19
+ import urllib.request
20
+ from pathlib import Path
21
+
22
+ PACKAGE_ROOT = Path(__file__).resolve().parent
23
+ PKG_FALLBACK_NAME = "dsh-context-guardian"
24
+ DEFAULT_PORT = 8786
25
+ PROXY_DEFAULT_NUM_CTX = 32768
26
+ HELLO_IN = '{"id":1,"op":"hello"}\n{"id":2,"op":"shutdown"}\n'
27
+ RESERVE_FIX = "lower GUARDIAN_RESERVE_OUTPUT or raise GUARDIAN_NUM_CTX"
28
+ NUMCTX_RE = re.compile(r"^\s*numCtx\s*:\s*(\d+)\s*(#.*)?$", re.MULTILINE)
29
+ VERSION_RE = re.compile(r'^__version__\s*=\s*"([^"]+)"', re.MULTILINE)
30
+ PYVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
31
+ # (key, smallest accepted whole number) -- one table, one loop, no copy-paste
32
+ WHOLE_KEYS = (("GUARDIAN_NUM_CTX", 1), ("GUARDIAN_RESERVE_OUTPUT", 0),
33
+ ("GUARDIAN_KEEP_RECENT_MESSAGES", 0), ("GUARDIAN_PORT", 1))
34
+
35
+ def _f(level, what, fix=""):
36
+ return {"level": level, "what": what, "fix": fix}
37
+
38
+ def _whole(raw, minimum=0):
39
+ """int(raw) when raw is digits only and >= minimum, else None."""
40
+ text = (raw or "").strip()
41
+ if not text.isdigit():
42
+ return None
43
+ number = int(text)
44
+ return number if number >= minimum else None
45
+
46
+ def read_env_file(path: Path) -> dict:
47
+ """KEY=VALUE per line; skips blank lines, `#` comments and lines without `=`, and
48
+ strips one pair of matching surrounding quotes. Missing/unreadable -> {}; never raises."""
49
+ out = {}
50
+ try:
51
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
52
+ except OSError:
53
+ return out
54
+ for line in text.splitlines():
55
+ if "=" not in line:
56
+ continue
57
+ key, _, value = (part.strip() for part in line.strip().partition("="))
58
+ if key.startswith("#"):
59
+ continue
60
+ if len(value) > 1 and value[0] == value[-1] and value[0] in "\"'":
61
+ value = value[1:-1]
62
+ if key:
63
+ out[key] = value
64
+ return out
65
+
66
+ def find_preset_num_ctx(preset: Path | None) -> int | None:
67
+ """The first non-commented `numCtx: <digits>` in a DSH preset, else None."""
68
+ try:
69
+ text = Path(preset).read_text(encoding="utf-8", errors="replace") if preset else ""
70
+ except OSError:
71
+ return None
72
+ match = NUMCTX_RE.search(text)
73
+ return int(match.group(1)) if match else None
74
+
75
+ def resolve_window(env: dict, preset_num_ctx: int | None) -> dict:
76
+ """A positive whole-number env GUARDIAN_NUM_CTX wins, then the preset, else the host;
77
+ a bad env value falls through here -- check 2 is what reports it."""
78
+ value = _whole(env.get("GUARDIAN_NUM_CTX"), 1)
79
+ if value is not None:
80
+ return {"source": "env", "value": value}
81
+ if preset_num_ctx is not None:
82
+ return {"source": "preset", "value": preset_num_ctx}
83
+ return {"source": "host", "value": None}
84
+
85
+ def find_python(root: Path, env: dict, which, os_name: str = os.name) -> str | None:
86
+ """Same order as the JS plugin: $GUARDIAN_PYTHON, a .venv beside the package, then
87
+ python / python3 on PATH. The env value is used as given (no existence test) so a
88
+ typo surfaces as a bridge failure that has a fix."""
89
+ given = (env.get("GUARDIAN_PYTHON") or "").strip()
90
+ if given:
91
+ return given
92
+ if os_name == "nt":
93
+ candidate = Path(root) / ".venv" / "Scripts" / "python.exe"
94
+ else:
95
+ candidate = Path(root) / ".venv" / "bin" / "python"
96
+ return str(candidate) if candidate.is_file() else which("python" if os_name == "nt" else "python3")
97
+
98
+ def _http_get(url: str, timeout: float) -> tuple[int, str]:
99
+ """urllib GET. Raises OSError (URLError is one) on failure -- never a fake status."""
100
+ opener = urllib.request.build_opener()
101
+ with opener.open(url, timeout=timeout) as response: # loopback URL built by the caller
102
+ return int(response.getcode()), response.read().decode("utf-8", "replace")
103
+
104
+ def _run_bridge(argv: list[str], stdin_text: str, timeout: float) -> tuple[int, str]:
105
+ """Run the bridge with an argv list, never a shell. A timeout or OSError comes back
106
+ as (-1, "<ExceptionName>: <message>") instead of raising."""
107
+ try:
108
+ # argv is a list and shell is False, and the interpreter is the user's own
109
+ # GUARDIAN_PYTHON / .venv / PATH choice -- so B603 does not apply here.
110
+ proc = subprocess.run(argv, input=stdin_text, capture_output=True, # nosec B603
111
+ text=True, timeout=timeout, shell=False, check=False)
112
+ return proc.returncode, proc.stdout
113
+ except (OSError, ValueError) as exc:
114
+ return -1, f"{type(exc).__name__}: {exc}"
115
+
116
+ def _bridge_version(out: str):
117
+ """(X.Y.Z, (X, Y)) from the bridge's first non-empty line, else (None, None)."""
118
+ first = next((ln for ln in out.splitlines() if ln.strip()), "")
119
+ try:
120
+ reply = json.loads(first)
121
+ except ValueError:
122
+ return None, None
123
+ result = reply.get("result") if isinstance(reply, dict) and reply.get("ok") is True else None
124
+ version = result.get("python") if isinstance(result, dict) else None
125
+ parts = PYVER_RE.match(version) if isinstance(version, str) else None
126
+ return (version, (int(parts.group(1)), int(parts.group(2)))) if parts else (None, None)
127
+
128
+ def _number_findings(work: dict) -> list[dict]:
129
+ """Check 2's numbers: one loop over WHOLE_KEYS, the threshold, then the pair."""
130
+ found, nums = [], {}
131
+ for key, minimum in WHOLE_KEYS:
132
+ raw = (work.get(key) or "").strip()
133
+ if not raw:
134
+ continue
135
+ number = _whole(raw, minimum)
136
+ if number is None:
137
+ found.append(_f("error", f"{key}={raw} is not a whole number",
138
+ f"set {key} to digits only (e.g. 8192) in your .env or environment"))
139
+ else:
140
+ nums[key] = number
141
+ threshold = (work.get("GUARDIAN_COMPACT_THRESHOLD") or "").strip()
142
+ try:
143
+ share = float(threshold) if threshold else None
144
+ except ValueError:
145
+ share = None
146
+ if threshold and (share is None or not 0 < share < 1):
147
+ found.append(_f("error", f"GUARDIAN_COMPACT_THRESHOLD={threshold} must be between 0 and 1",
148
+ "use a fraction such as 0.85"))
149
+ num, reserve = nums.get("GUARDIAN_NUM_CTX"), nums.get("GUARDIAN_RESERVE_OUTPUT")
150
+ if num is not None and reserve is not None and reserve >= num:
151
+ found.append(_f("error", f"GUARDIAN_RESERVE_OUTPUT ({reserve}) is not below GUARDIAN_NUM_CTX ({num})",
152
+ RESERVE_FIX))
153
+ return found
154
+
155
+ def doctor(root: Path = PACKAGE_ROOT, env: dict | None = None, preset: Path | None = None,
156
+ http_get=None, run=None, which=shutil.which) -> list[dict]:
157
+ """Run every check and return the findings, in check order."""
158
+ root = Path(root)
159
+ if env is None:
160
+ env = dict(os.environ)
161
+ get, run_it = (_http_get if http_get is None else http_get), (_run_bridge if run is None else run)
162
+ # the process environment WINS over .env, as the proxy's load_dotenv does.
163
+ work = read_env_file(root / ".env")
164
+ work.update(env)
165
+ out = []
166
+
167
+ # 1. package + proxy version
168
+ name, data = PKG_FALLBACK_NAME, None
169
+ try:
170
+ data = json.loads((root / "package.json").read_text(encoding="utf-8"))
171
+ except OSError:
172
+ data = None
173
+ except ValueError:
174
+ data = "bad"
175
+ if isinstance(data, dict) and isinstance(data.get("version"), str) and data["version"]:
176
+ name = str(data.get("name") or PKG_FALLBACK_NAME)
177
+ out.append(_f("ok", "package: {} {}".format(name, data["version"])))
178
+ else:
179
+ bad = "package.json is not valid JSON" if data == "bad" else "package.json not found"
180
+ out.append(_f("error", bad, "run the doctor from the installed package folder (npm ls dsh-context-guardian shows where)"))
181
+ try:
182
+ match = VERSION_RE.search((root / "context_guardian.py").read_text(encoding="utf-8", errors="replace"))
183
+ except OSError:
184
+ match = None
185
+ if match:
186
+ out.append(_f("ok", f"proxy: context_guardian.py {match.group(1)}"))
187
+ else:
188
+ out.append(_f("warn", "proxy version not found", f"reinstall the package: npm i {name}@latest"))
189
+
190
+ # 2. .env and the numbers in it
191
+ envfile = root / ".env"
192
+ out.append(_f("ok", f"using .env: {envfile}" if envfile.is_file() else "no .env (optional)"))
193
+ out.extend(_number_findings(work))
194
+
195
+ # 3. the window both halves will use
196
+ w = resolve_window(work, find_preset_num_ctx(preset))
197
+ if w["source"] == "env":
198
+ out.append(_f("ok", f"window: {w['value']} tokens from GUARDIAN_NUM_CTX (engine and proxy)"))
199
+ elif w["source"] == "preset":
200
+ out.append(_f("warn", f"window: pinned to {w['value']} by numCtx in {preset}",
201
+ "delete the numCtx line so the engine uses the window DSH reports for your model; "
202
+ f"keep it only if {w['value']} really is your model's window"))
203
+ else:
204
+ out.append(_f("ok", "window: the engine uses the window DSH reports for the session; "
205
+ f"the proxy uses its default {PROXY_DEFAULT_NUM_CTX}"))
206
+
207
+ # 4. the proxy
208
+ host = (work.get("GUARDIAN_HOST") or "").strip() or "127.0.0.1"
209
+ port = _whole(work.get("GUARDIAN_PORT"), 1) or DEFAULT_PORT
210
+ url = f"http://{host}:{port}/guardian/stats"
211
+ stats = None
212
+ try:
213
+ status, body = get(url, 2.0)
214
+ candidate = json.loads(body) if status == 200 else None
215
+ stats = candidate if isinstance(candidate, dict) else None
216
+ except Exception: # noqa: BLE001 -- any transport or parse failure means "not reachable"
217
+ stats = None
218
+ if stats is None:
219
+ out.append(_f("warn", f"proxy not reachable at {url}", "optional: the DSH bundle works without it. To run it: python context_guardian.py"))
220
+ else:
221
+ running = stats.get("num_ctx")
222
+ whole = isinstance(running, int) and not isinstance(running, bool)
223
+ shown = f" (num_ctx {running})" if whole else ""
224
+ out.append(_f("ok", f"proxy reachable at {url}{shown}"))
225
+ if w["source"] == "env" and whole and running != w["value"]:
226
+ out.append(_f("warn", f"proxy is running with num_ctx {running} but GUARDIAN_NUM_CTX is {w['value']}",
227
+ "restart the proxy so it re-reads GUARDIAN_NUM_CTX"))
228
+
229
+ # 5. the Python bridge
230
+ bridge = root / "modules" / "cg_bridge.py"
231
+ if not bridge.is_file():
232
+ out.append(_f("error", "modules/cg_bridge.py is missing", f"reinstall the package: npm i {PKG_FALLBACK_NAME}@latest"))
233
+ else:
234
+ py = find_python(root, work, which)
235
+ if not py:
236
+ out.append(_f("error", "no Python found for the bridge", "install Python 3.10+ or set GUARDIAN_PYTHON to its full path"))
237
+ else:
238
+ try:
239
+ code, said = run_it([py, str(bridge)], HELLO_IN, 20.0)
240
+ except Exception as exc: # noqa: BLE001 -- an injected runner may raise
241
+ code, said = -1, f"{type(exc).__name__}: {exc}"
242
+ version, series = _bridge_version(said or "")
243
+ if series is not None and series < (3, 10):
244
+ out.append(_f("error", f"python bridge runs Python {version}; 3.10+ is required",
245
+ "set GUARDIAN_PYTHON to a Python 3.10+ interpreter"))
246
+ elif series is not None:
247
+ out.append(_f("ok", f"python bridge works: {py} (Python {version})"))
248
+ else:
249
+ detail = (said or "").strip()[-200:] or (f"exit code {code}")
250
+ out.append(_f("error", f"python bridge failed: {detail}",
251
+ f"run: {py} modules/cg_bridge.py --selftest and check GUARDIAN_PYTHON"))
252
+ return out
253
+
254
+ def render(findings) -> tuple[str, int]:
255
+ """The text report and its exit code: 1 when any finding is an error."""
256
+ lines, counts = [], {"ok": 0, "warn": 0, "error": 0}
257
+ for finding in findings:
258
+ level = finding["level"]
259
+ counts[level] = counts.get(level, 0) + 1
260
+ lines.append("{} {}".format({"ok": "OK ", "warn": "WARN", "error": "FAIL"}[level], finding["what"]))
261
+ if level != "ok" and finding["fix"]:
262
+ lines.append(" fix: {}".format(finding["fix"]))
263
+ lines.append(f"doctor: {counts['ok']} ok, {counts['warn']} warnings, {counts['error']} errors")
264
+ return "\n".join(lines), (1 if counts["error"] else 0)
265
+
266
+ def main(argv=None) -> int:
267
+ args = list(sys.argv[1:] if argv is None else argv)
268
+ if "--selftest" in args:
269
+ return selftest()
270
+ parser = argparse.ArgumentParser(prog="cg_doctor.py", description="Check a context-guardian install and say how to fix it.")
271
+ parser.add_argument("--preset", type=Path, default=None, help="a DSH preset / cordis.patch.yml to check for a pinned numCtx")
272
+ parser.add_argument("--json", action="store_true", help="print the findings as JSON")
273
+ ns = parser.parse_args(args)
274
+ findings = doctor(preset=ns.preset)
275
+ if ns.json:
276
+ print(json.dumps(findings, indent=2))
277
+ return 1 if any(f["level"] == "error" for f in findings) else 0
278
+ text, code = render(findings)
279
+ print(text)
280
+ return code
281
+
282
+ # --- selftest: real comparisons over temp-dir fixtures; http_get/run/which are fakes.
283
+ def _plant(root, with_pkg=True, proxy="9.9.9"):
284
+ root = Path(root)
285
+ if with_pkg:
286
+ (root / "package.json").write_text(f'{{"name": "{PKG_FALLBACK_NAME}", "version": "1.2.3"}}', encoding="utf-8")
287
+ if proxy is not None:
288
+ (root / "context_guardian.py").write_text(f'__version__ = "{proxy}"\n', encoding="utf-8")
289
+ (root / "modules").mkdir(parents=True, exist_ok=True)
290
+ (root / "modules" / "cg_bridge.py").write_text("# bridge\n", encoding="utf-8")
291
+ return root
292
+
293
+ def _up(url, timeout):
294
+ return 200, '{"num_ctx": 65536}'
295
+
296
+ def _good_run(argv, stdin_text, timeout):
297
+ return 0, '{"id":1,"ok":true,"result":{"python":"3.11.9"}}\n'
298
+
299
+ def _dr(root, env=None, preset=None, http_get=None, run=None):
300
+ """doctor() over a planted root with every door faked."""
301
+ return doctor(root=root, env=env if env is not None else {"GUARDIAN_PYTHON": "py"}, preset=preset,
302
+ http_get=http_get or _up, run=run or _good_run, which=lambda name: None)
303
+
304
+ def _levels(findings, level):
305
+ return [f for f in findings if f["level"] == level]
306
+
307
+ def _check_env_file_parses_quotes_and_comments():
308
+ with tempfile.TemporaryDirectory() as t:
309
+ path = Path(t) / ".env"
310
+ path.write_text('A="x"\n# B=1\nC = 3\njunk\n', encoding="utf-8")
311
+ return read_env_file(path) == {"A": "x", "C": "3"}
312
+
313
+ def _check_env_file_missing_is_empty():
314
+ with tempfile.TemporaryDirectory() as t:
315
+ return read_env_file(Path(t) / "nope.env") == {}
316
+
317
+ def _check_preset_numctx_found_and_comment_ignored():
318
+ with tempfile.TemporaryDirectory() as t:
319
+ path = Path(t) / "cordis.patch.yml"
320
+ path.write_text(" config:\n # numCtx: 1\n numCtx: 65536\n", encoding="utf-8")
321
+ return (find_preset_num_ctx(path) == 65536 and find_preset_num_ctx(Path(t) / "gone.yml") is None
322
+ and find_preset_num_ctx(None) is None)
323
+
324
+ def _check_window_env_beats_preset():
325
+ return resolve_window({"GUARDIAN_NUM_CTX": "65536"}, 4096) == {"source": "env", "value": 65536}
326
+
327
+ def _check_window_preset_when_no_env():
328
+ return resolve_window({}, 4096) == {"source": "preset", "value": 4096}
329
+
330
+ def _check_window_host_when_neither():
331
+ return resolve_window({}, None) == {"source": "host", "value": None}
332
+
333
+ def _check_window_bad_env_value_falls_through():
334
+ return resolve_window({"GUARDIAN_NUM_CTX": "abc"}, 4096) == {"source": "preset", "value": 4096}
335
+
336
+ def _check_find_python_prefers_env_then_venv_then_path():
337
+ with tempfile.TemporaryDirectory() as t:
338
+ root, asked = Path(t), []
339
+
340
+ def which(name):
341
+ asked.append(name)
342
+ return "PATH-" + name
343
+
344
+ venv = root / ".venv" / "Scripts" / "python.exe"
345
+ venv.parent.mkdir(parents=True)
346
+ venv.write_text("x", encoding="utf-8")
347
+ from_venv = find_python(root, {}, which, os_name="nt")
348
+ venv.unlink()
349
+ from_nt, from_path = find_python(root, {}, which, os_name="nt"), find_python(root, {}, which, "posix")
350
+ from_env = find_python(root, {"GUARDIAN_PYTHON": " C:/py/python.exe "}, which, os_name="nt")
351
+ return (from_venv == str(venv) and from_nt == "PATH-python" and from_path == "PATH-python3"
352
+ and from_env == "C:/py/python.exe" and asked == ["python", "python3"])
353
+
354
+ def _check_default_root_is_the_file_folder():
355
+ return (PACKAGE_ROOT == Path(__file__).resolve().parent and PACKAGE_ROOT.name != "modules"
356
+ and PACKAGE_ROOT.is_dir())
357
+
358
+ def _check_all_good_has_no_errors():
359
+ with tempfile.TemporaryDirectory() as t:
360
+ findings = _dr(_plant(t), env={"GUARDIAN_NUM_CTX": "65536", "GUARDIAN_PYTHON": "py"})
361
+ return len(findings) == 6 and all(f["level"] == "ok" for f in findings)
362
+
363
+ def _check_missing_package_json_is_error():
364
+ with tempfile.TemporaryDirectory() as t:
365
+ return any("package.json not found" in f["what"] and f["fix"]
366
+ for f in _levels(_dr(_plant(t, with_pkg=False)), "error"))
367
+
368
+ def _check_bad_numeric_is_error_with_fix():
369
+ with tempfile.TemporaryDirectory() as t:
370
+ errors = _levels(_dr(_plant(t), env={"GUARDIAN_PYTHON": "py", "GUARDIAN_NUM_CTX": "32k"}), "error")
371
+ return len(errors) == 1 and "GUARDIAN_NUM_CTX=32k" in errors[0]["what"] and bool(errors[0]["fix"])
372
+
373
+ def _check_reserve_not_below_window_is_error():
374
+ with tempfile.TemporaryDirectory() as t:
375
+ env = {"GUARDIAN_PYTHON": "py", "GUARDIAN_NUM_CTX": "8192", "GUARDIAN_RESERVE_OUTPUT": "9999"}
376
+ return any("GUARDIAN_RESERVE_OUTPUT (9999) is not below GUARDIAN_NUM_CTX (8192)" in f["what"]
377
+ and RESERVE_FIX in f["fix"] for f in _levels(_dr(_plant(t), env=env), "error"))
378
+
379
+ def _check_preset_pin_is_warn():
380
+ with tempfile.TemporaryDirectory() as t:
381
+ root = _plant(t)
382
+ preset = root / "cordis.patch.yml"
383
+ preset.write_text("config:\n numCtx: 32768\n", encoding="utf-8")
384
+ return any("pinned to 32768" in f["what"] and f["fix"] for f in _levels(_dr(root, preset=preset), "warn"))
385
+
386
+ def _check_proxy_down_is_warn_not_error():
387
+ def down(url, timeout):
388
+ raise OSError("refused")
389
+
390
+ with tempfile.TemporaryDirectory() as t:
391
+ findings = _dr(_plant(t), http_get=down)
392
+ wanted = f"proxy not reachable at http://127.0.0.1:{DEFAULT_PORT}/guardian/stats"
393
+ return any(wanted in f["what"] and f["fix"] for f in _levels(findings, "warn")) \
394
+ and not _levels(findings, "error")
395
+
396
+ def _check_proxy_window_mismatch_is_warn():
397
+ def stale(url, timeout):
398
+ return 200, '{"num_ctx": 32768}'
399
+
400
+ with tempfile.TemporaryDirectory() as t:
401
+ env = {"GUARDIAN_PYTHON": "py", "GUARDIAN_NUM_CTX": "65536"}
402
+ warns = _levels(_dr(_plant(t), env=env, http_get=stale), "warn")
403
+ return any("num_ctx 32768 but GUARDIAN_NUM_CTX is 65536" in f["what"]
404
+ and "restart the proxy" in f["fix"] for f in warns)
405
+
406
+ def _check_bridge_failure_is_error():
407
+ def crashed(argv, stdin_text, timeout):
408
+ return 1, "Traceback (most recent call last): boom\n"
409
+
410
+ with tempfile.TemporaryDirectory() as t:
411
+ return any("python bridge failed" in f["what"] and "boom" in f["what"] and f["fix"]
412
+ for f in _levels(_dr(_plant(t), run=crashed), "error"))
413
+
414
+ def _check_bridge_old_python_is_error():
415
+ def old(argv, stdin_text, timeout):
416
+ return 0, '{"id":1,"ok":true,"result":{"python":"3.8.10"}}\n'
417
+
418
+ with tempfile.TemporaryDirectory() as t:
419
+ return any("3.10+ is required" in f["what"] and "3.10+" in f["fix"]
420
+ for f in _levels(_dr(_plant(t), run=old), "error"))
421
+
422
+ def _check_cli_exit_codes():
423
+ text, code = render([{"level": "error", "what": "x", "fix": "do y"}, {"level": "ok", "what": "z", "fix": ""}])
424
+ ok_text, ok_code = render([{"level": "ok", "what": "z", "fix": ""}])
425
+ last = text.strip().splitlines()[-1]
426
+ return (code == 1 and ok_code == 0 and "FAIL x" in text and "fix: do y" in text
427
+ and re.fullmatch(r"doctor: 1 ok, 0 warnings, 1 errors", last) is not None
428
+ and ok_text.strip().splitlines()[-1] == "doctor: 1 ok, 0 warnings, 0 errors")
429
+
430
+ CHECKS = [("env_file_parses_quotes_and_comments", _check_env_file_parses_quotes_and_comments), ("env_file_missing_is_empty", _check_env_file_missing_is_empty), ("preset_numctx_found_and_comment_ignored", _check_preset_numctx_found_and_comment_ignored), ("window_env_beats_preset", _check_window_env_beats_preset), ("window_preset_when_no_env", _check_window_preset_when_no_env), ("window_host_when_neither", _check_window_host_when_neither), ("window_bad_env_value_falls_through", _check_window_bad_env_value_falls_through), ("find_python_prefers_env_then_venv_then_path", _check_find_python_prefers_env_then_venv_then_path), ("default_root_is_the_file_folder", _check_default_root_is_the_file_folder), ("all_good_has_no_errors", _check_all_good_has_no_errors), ("missing_package_json_is_error", _check_missing_package_json_is_error), ("bad_numeric_is_error_with_fix", _check_bad_numeric_is_error_with_fix), ("reserve_not_below_window_is_error", _check_reserve_not_below_window_is_error), ("preset_pin_is_warn", _check_preset_pin_is_warn), ("proxy_down_is_warn_not_error", _check_proxy_down_is_warn_not_error), ("proxy_window_mismatch_is_warn", _check_proxy_window_mismatch_is_warn), ("bridge_failure_is_error", _check_bridge_failure_is_error), ("bridge_old_python_is_error", _check_bridge_old_python_is_error), ("cli_exit_codes", _check_cli_exit_codes)]
431
+
432
+ def selftest() -> int:
433
+ total, passed = 0, 0
434
+ for name, function in CHECKS:
435
+ detail = ""
436
+ try:
437
+ ok = bool(function())
438
+ except Exception as exc: # noqa: BLE001 -- a crash is a failed check
439
+ ok, detail = False, f" ({type(exc).__name__}: {exc})"
440
+ if ok:
441
+ passed += 1
442
+ print(" {} {}{}".format("ok " if ok else "FAIL", name, detail))
443
+ total += 1
444
+ failed = total - passed
445
+ print(f"cg_doctor selftest: {total} checks, {passed} passed, {failed} failed")
446
+ return 1 if failed or total == 0 else 0
447
+
448
+ if __name__ == "__main__":
449
+ sys.exit(main())