cendor-init 0.1.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.
@@ -0,0 +1,15 @@
1
+ """cendor-init — offline CLI to wire Cendor + your AI assistant (``init``) and validate the wiring
2
+ (``doctor``). Optional developer tooling; no Cendor library depends on it, and it makes no network
3
+ call. See https://cendor.ai/docs/for-ai-assistants.
4
+
5
+ This is a standalone top-level package (``cendor_init``) — NOT part of the PEP 420 ``cendor.*``
6
+ namespace — mirroring how ``cendor-mcp`` ships ``cendor_mcp``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .doctor import run_doctor
12
+ from .initialize import run_init
13
+
14
+ __all__ = ["run_init", "run_doctor", "__version__"]
15
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m cendor_init`` in addition to the ``cendor-init`` console script / ``uvx``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
cendor_init/cli.py ADDED
@@ -0,0 +1,188 @@
1
+ """``cendor-init`` — one command to make a project Cendor-ready and Cendor-fluent for its AI
2
+ assistant, plus a ``doctor`` that catches wiring mistakes before they bite. Offline: no network, no key.
3
+
4
+ uvx cendor-init # detect + write assistant rules (idempotent)
5
+ uvx cendor-init doctor # validate wiring; non-zero exit on hard problems (CI-usable)
6
+
7
+ See https://cendor.ai/docs/for-ai-assistants and https://cendor.ai/mcp.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ import textwrap
15
+ from pathlib import Path
16
+
17
+ from .doctor import SEVERITY_RANK, Finding, run_doctor
18
+ from .initialize import ALL_ASSISTANTS, InitOptions, run_init
19
+
20
+ __all__ = ["main"]
21
+
22
+ _ICON = {
23
+ "created": "+",
24
+ "updated": "~",
25
+ "appended": "~",
26
+ "skipped": "·",
27
+ "would-create": "+",
28
+ "would-update": "~",
29
+ "would-append": "~",
30
+ "would-skip": "·",
31
+ }
32
+
33
+
34
+ def _version() -> str:
35
+ try:
36
+ from importlib import metadata
37
+
38
+ return metadata.version("cendor-init")
39
+ except Exception:
40
+ return "0.1.0"
41
+
42
+
43
+ def _parse_assistants(raw: list[str] | None) -> list[str] | None:
44
+ if not raw:
45
+ return None
46
+ wanted: list[str] = []
47
+ for item in raw:
48
+ for part in item.split(","):
49
+ part = part.strip().lower()
50
+ if part and part not in wanted:
51
+ wanted.append(part)
52
+ bad = [w for w in wanted if w not in ALL_ASSISTANTS]
53
+ if bad:
54
+ sys.stderr.write(f"cendor-init: unknown assistant(s): {', '.join(bad)}\n")
55
+ sys.stderr.write(f" valid: {', '.join(ALL_ASSISTANTS)}\n")
56
+ raise SystemExit(2)
57
+ return wanted
58
+
59
+
60
+ def _cmd_init(args: argparse.Namespace) -> int:
61
+ root = Path.cwd()
62
+ opts = InitOptions(
63
+ root=root,
64
+ assistants=_parse_assistants(args.assistant),
65
+ all=args.all,
66
+ mcp=args.mcp,
67
+ scaffold=args.scaffold,
68
+ force=args.force,
69
+ dry_run=args.dry_run,
70
+ )
71
+ result = run_init(opts)
72
+ dry = opts.dry_run
73
+ eco = result.detected.ecosystem
74
+
75
+ out = sys.stdout.write
76
+ out(
77
+ f"\ncendor-init — {'dry run (no files written)' if dry else 'wiring Cendor for your assistant'}\n"
78
+ )
79
+ line = "no package.json / pyproject found" if eco == "unknown" else f"{eco} project"
80
+ if result.detected.declared_providers:
81
+ line += f" · providers: {', '.join(sorted(result.detected.declared_providers))}"
82
+ out(f"project: {line}\n\n")
83
+
84
+ for a in result.actions:
85
+ icon = _ICON.get(a.status, "·")
86
+ note = f" ({a.note})" if a.note else ""
87
+ out(f" {icon} {a.status:<13} {a.path}{note}\n")
88
+
89
+ out("\nMCP (agent-mode assistants):\n")
90
+ for ln in result.mcp_guidance.split("\n"):
91
+ out(f" {ln}\n")
92
+
93
+ out("\nNext:\n")
94
+ out(" • Trust your editor's hover/completion — every Cendor symbol ships an @example.\n")
95
+ out(" • Full trap sheet: https://cendor.ai/docs/for-ai-assistants\n")
96
+ out(" • Validate wiring: uvx cendor-init doctor\n\n")
97
+ return 0
98
+
99
+
100
+ def _print_finding(f: Finding) -> None:
101
+ tag = {"error": "ERROR", "warn": "WARN ", "ok": "OK "}.get(f.severity, "INFO ")
102
+ out = sys.stdout.write
103
+ out(f" [{tag}] {f.title}\n")
104
+ for ln in textwrap.wrap(f.detail, width=88):
105
+ out(f" {ln}\n")
106
+ if f.fix:
107
+ out(f" fix: {f.fix}\n")
108
+ for loc in f.locations:
109
+ out(f" - {loc}\n")
110
+ out("\n")
111
+
112
+
113
+ def _cmd_doctor(_args: argparse.Namespace) -> int:
114
+ result = run_doctor(Path.cwd())
115
+ sys.stdout.write("\ncendor-init doctor\n\n")
116
+ for f in sorted(result.findings, key=lambda x: SEVERITY_RANK.get(x.severity, 9)):
117
+ _print_finding(f)
118
+ errors = sum(1 for f in result.findings if f.severity == "error")
119
+ warns = sum(1 for f in result.findings if f.severity == "warn")
120
+ tail = "OK." if result.exit_code == 0 else "Fix the errors above."
121
+ sys.stdout.write(f"{errors} error(s), {warns} warning(s). {tail}\n\n")
122
+ return result.exit_code
123
+
124
+
125
+ def _build_parser() -> argparse.ArgumentParser:
126
+ parser = argparse.ArgumentParser(
127
+ prog="cendor-init",
128
+ description="Wire Cendor + your AI assistant, offline.",
129
+ formatter_class=argparse.RawDescriptionHelpFormatter,
130
+ epilog="Docs: https://cendor.ai/docs/for-ai-assistants MCP: https://cendor.ai/mcp",
131
+ )
132
+ parser.add_argument("-v", "--version", action="version", version=_version())
133
+ sub = parser.add_subparsers(dest="command")
134
+
135
+ p_init = sub.add_parser("init", help="write assistant rules files (default command)")
136
+ _add_init_args(p_init)
137
+ sub.add_parser("doctor", help="validate the wiring (never writes); exit 1 on hard problems")
138
+ return parser
139
+
140
+
141
+ def _add_init_args(p: argparse.ArgumentParser) -> None:
142
+ p.add_argument("--all", action="store_true", help="write every assistant rules file")
143
+ p.add_argument(
144
+ "--assistant",
145
+ action="append",
146
+ metavar="LIST",
147
+ help="comma-separated subset: copilot,cursor,agents,claude,windsurf",
148
+ )
149
+ p.add_argument("--mcp", action="store_true", help="also drop MCP connect config where absent")
150
+ p.add_argument(
151
+ "--scaffold", action="store_true", help="also write a correct instrument()+budget starter"
152
+ )
153
+ p.add_argument(
154
+ "--force", action="store_true", help="overwrite an owned file (.cursor/rules/cendor.mdc)"
155
+ )
156
+ p.add_argument("--dry-run", action="store_true", help="show what would change without writing")
157
+ p.add_argument("-y", "--yes", action="store_true", help=argparse.SUPPRESS)
158
+
159
+
160
+ def _force_utf8() -> None:
161
+ """Our output uses em-dashes / bullets. A Windows console defaults to a non-UTF-8 code page,
162
+ which would raise UnicodeEncodeError on those — force UTF-8 (with a replace fallback)."""
163
+ for stream in (sys.stdout, sys.stderr):
164
+ reconfigure = getattr(stream, "reconfigure", None)
165
+ if reconfigure is not None:
166
+ try:
167
+ reconfigure(encoding="utf-8", errors="replace")
168
+ except (ValueError, OSError): # pragma: no cover - stream doesn't support it
169
+ pass
170
+
171
+
172
+ def main(argv: list[str] | None = None) -> int:
173
+ _force_utf8()
174
+ argv = list(sys.argv[1:] if argv is None else argv)
175
+ # Default to `init` when no subcommand is given (mirrors `npx @cendor/init`).
176
+ if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help", "-v", "--version")):
177
+ argv = ["init", *argv]
178
+
179
+ parser = _build_parser()
180
+ args = parser.parse_args(argv)
181
+ command = args.command or "init"
182
+ if command == "doctor":
183
+ return _cmd_doctor(args)
184
+ return _cmd_init(args)
185
+
186
+
187
+ if __name__ == "__main__": # pragma: no cover
188
+ raise SystemExit(main())
cendor_init/detect.py ADDED
@@ -0,0 +1,226 @@
1
+ """Detect the project shape, which assistants are configured, which providers / cendor packages exist."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from importlib import metadata
9
+ from pathlib import Path
10
+
11
+ # PyPI provider distribution / import token -> normalized provider key.
12
+ PYPI_PROVIDERS: dict[str, str] = {
13
+ "openai": "openai",
14
+ "anthropic": "anthropic",
15
+ "google-genai": "google",
16
+ "google-generativeai": "google",
17
+ "google.genai": "google",
18
+ "google_genai": "google",
19
+ "boto3": "bedrock",
20
+ "ollama": "ollama",
21
+ "mistralai": "mistral",
22
+ "cohere": "cohere",
23
+ "huggingface_hub": "huggingface",
24
+ "huggingface-hub": "huggingface",
25
+ }
26
+
27
+ # npm provider package -> normalized provider key (for the cross-ecosystem "used in .ts source" note).
28
+ NPM_PROVIDERS: dict[str, str] = {
29
+ "openai": "openai",
30
+ "@anthropic-ai/sdk": "anthropic",
31
+ "@google/genai": "google",
32
+ "@google/generative-ai": "google",
33
+ "@aws-sdk/client-bedrock-runtime": "bedrock",
34
+ "ollama": "ollama",
35
+ "@mistralai/mistralai": "mistral",
36
+ "cohere-ai": "cohere",
37
+ }
38
+
39
+ # The PyPI distribution that ships each provider SDK (for the "install X" fix hint).
40
+ PYPI_PKG_FOR_PROVIDER: dict[str, str] = {
41
+ "openai": "openai",
42
+ "anthropic": "anthropic",
43
+ "google": "google-genai",
44
+ "bedrock": "boto3",
45
+ "ollama": "ollama",
46
+ "mistral": "mistralai",
47
+ "cohere": "cohere",
48
+ "huggingface": "huggingface_hub",
49
+ }
50
+
51
+ # The cendor-sdk extra that pulls each provider (nicer fix hint than the bare package).
52
+ SDK_EXTRA_FOR_PROVIDER: dict[str, str] = {
53
+ "openai": "openai",
54
+ "anthropic": "anthropic",
55
+ "google": "google",
56
+ "bedrock": "bedrock",
57
+ "ollama": "ollama",
58
+ "huggingface": "huggingface",
59
+ }
60
+
61
+ CENDOR_DISTS = list(
62
+ {
63
+ "cendor-core",
64
+ "cendor-tokenguard",
65
+ "cendor-guardrails",
66
+ "cendor-contextkit",
67
+ "cendor-squeeze",
68
+ "cendor-cassette",
69
+ "cendor-acttrace",
70
+ "cendor-libs",
71
+ "cendor",
72
+ "cendor-sdk",
73
+ }
74
+ )
75
+
76
+ _PY_IMPORT_RE = re.compile(
77
+ r"(?:^|\n)[ \t]*(?:import|from)[ \t]+"
78
+ r"(openai|anthropic|google\.genai|google_genai|boto3|ollama|mistralai|cohere|huggingface_hub)\b"
79
+ )
80
+ _NPM_IMPORT_RE = re.compile(
81
+ r"""(?:from|require|import)\s*\(?\s*['"]"""
82
+ r"(openai|@anthropic-ai/sdk|@google/genai|@google/generative-ai|"
83
+ r"@aws-sdk/client-bedrock-runtime|ollama|@mistralai/mistralai|cohere-ai)['\"]"
84
+ )
85
+ # A dependency token like `cendor-core>=1.3,<2` or `openai` (quoted or bare) in pyproject/requirements.
86
+ _DEP_RE = re.compile(r"""["'\s]([A-Za-z][A-Za-z0-9._-]*)\s*((?:[<>=!~]=?|==)\s*[0-9][^"',\]]*)?""")
87
+
88
+
89
+ @dataclass
90
+ class Detected:
91
+ root: Path
92
+ ecosystem: str # "node" | "python" | "unknown"
93
+ node: bool
94
+ python: bool
95
+ declared_providers: set[str] = field(default_factory=set)
96
+ assistants: list[str] = field(default_factory=list)
97
+ # cendor-* -> version spec declared in pyproject/requirements.
98
+ declared_pypi: dict[str, str] = field(default_factory=dict)
99
+ # cendor-* -> installed version (importlib.metadata) if importable in THIS environment.
100
+ installed_pypi: dict[str, str] = field(default_factory=dict)
101
+ # @cendor/* -> range declared in package.json (cross-ecosystem hint).
102
+ declared_npm: dict[str, str] = field(default_factory=dict)
103
+
104
+
105
+ def installed_version(dist: str) -> str | None:
106
+ try:
107
+ return metadata.version(dist)
108
+ except metadata.PackageNotFoundError:
109
+ return None
110
+ except Exception: # pragma: no cover - defensive
111
+ return None
112
+
113
+
114
+ def _parse_py_deps(text: str) -> tuple[dict[str, str], set[str]]:
115
+ cendor: dict[str, str] = {}
116
+ providers: set[str] = set()
117
+ for m in _DEP_RE.finditer(text):
118
+ raw = (m.group(1) or "").lower()
119
+ spec = re.sub(r"\s+", "", m.group(2) or "")
120
+ base = raw.split("[")[0] # strip extras like cendor-sdk[all]
121
+ if base.startswith("cendor"):
122
+ cendor[base] = spec or "*"
123
+ if base in PYPI_PROVIDERS:
124
+ providers.add(PYPI_PROVIDERS[base])
125
+ return cendor, providers
126
+
127
+
128
+ def _detect_assistants(root: Path) -> list[str]:
129
+ found: list[str] = []
130
+ if (root / ".github").exists():
131
+ found.append("copilot")
132
+ if (root / ".cursor").exists():
133
+ found.append("cursor")
134
+ if (root / "AGENTS.md").exists():
135
+ found.append("agents")
136
+ if (root / "CLAUDE.md").exists():
137
+ found.append("claude")
138
+ if (root / ".windsurf").exists():
139
+ found.append("windsurf")
140
+ return found
141
+
142
+
143
+ def _pkg_deps(pkg: dict) -> dict[str, str]:
144
+ out: dict[str, str] = {}
145
+ for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
146
+ block = pkg.get(key)
147
+ if isinstance(block, dict):
148
+ out.update({str(k): str(v) for k, v in block.items()})
149
+ return out
150
+
151
+
152
+ def detect_project(root: Path) -> Detected:
153
+ root = Path(root)
154
+ node = (root / "package.json").exists()
155
+ python = (
156
+ (root / "pyproject.toml").exists()
157
+ or (root / "setup.py").exists()
158
+ or (root / "setup.cfg").exists()
159
+ or any(
160
+ p.name.startswith("requirements") and p.suffix == ".txt"
161
+ for p in root.glob("requirements*.txt")
162
+ )
163
+ )
164
+ ecosystem = "node" if node else "python" if python else "unknown"
165
+
166
+ declared_providers: set[str] = set()
167
+ declared_pypi: dict[str, str] = {}
168
+ declared_npm: dict[str, str] = {}
169
+
170
+ if python:
171
+ for fname in ("pyproject.toml", "requirements.txt", "requirements-dev.txt", "setup.cfg"):
172
+ fpath = root / fname
173
+ if not fpath.exists():
174
+ continue
175
+ cendor, provs = _parse_py_deps(fpath.read_text(encoding="utf-8", errors="ignore"))
176
+ declared_pypi.update(cendor)
177
+ declared_providers |= provs
178
+
179
+ if node:
180
+ try:
181
+ pkg = json.loads((root / "package.json").read_text(encoding="utf-8", errors="ignore"))
182
+ for name, ver in _pkg_deps(pkg).items():
183
+ if name in NPM_PROVIDERS:
184
+ declared_providers.add(NPM_PROVIDERS[name])
185
+ if name.startswith("@cendor/"):
186
+ declared_npm[name] = ver
187
+ except (json.JSONDecodeError, OSError):
188
+ pass
189
+
190
+ # Installed cendor versions from the CURRENT environment (accurate when installed into the
191
+ # project venv; empty under `uvx` isolation, where we fall back to the declared pins above).
192
+ installed_pypi = {d: v for d in CENDOR_DISTS if (v := installed_version(d)) is not None}
193
+
194
+ return Detected(
195
+ root=root,
196
+ ecosystem=ecosystem,
197
+ node=node,
198
+ python=python,
199
+ declared_providers=declared_providers,
200
+ assistants=_detect_assistants(root),
201
+ declared_pypi=declared_pypi,
202
+ installed_pypi=installed_pypi,
203
+ declared_npm=declared_npm,
204
+ )
205
+
206
+
207
+ def providers_used_in_source(text: str, kind: str) -> set[str]:
208
+ used: set[str] = set()
209
+ if kind == "python":
210
+ for m in _PY_IMPORT_RE.finditer(text):
211
+ tok = m.group(1)
212
+ key = PYPI_PROVIDERS.get(tok) or PYPI_PROVIDERS.get(tok.replace(".", "_"))
213
+ if key:
214
+ used.add(key)
215
+ else:
216
+ for m in _NPM_IMPORT_RE.finditer(text):
217
+ key = NPM_PROVIDERS.get(m.group(1))
218
+ if key:
219
+ used.add(key)
220
+ return used
221
+
222
+
223
+ def provider_installed(provider: str) -> bool:
224
+ """Is the provider SDK importable in THIS environment?"""
225
+ pkg = PYPI_PKG_FOR_PROVIDER.get(provider)
226
+ return bool(pkg and installed_version(pkg) is not None)
cendor_init/doctor.py ADDED
@@ -0,0 +1,259 @@
1
+ """``doctor`` — validate the wiring so it doesn't break at runtime. Static checks only; NEVER mutates.
2
+
3
+ Exits non-zero on hard problems (CI-usable), zero when only warnings remain. Python is checked with
4
+ the installed environment (importlib.metadata) when available — accurate — and falls back to the
5
+ declared pins under ``uvx`` isolation. The npm ecosystem gets a "run @cendor/init doctor" pointer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from . import versions as version_snapshot
15
+ from .detect import (
16
+ PYPI_PKG_FOR_PROVIDER,
17
+ SDK_EXTRA_FOR_PROVIDER,
18
+ Detected,
19
+ detect_project,
20
+ provider_installed,
21
+ providers_used_in_source,
22
+ )
23
+ from .scan import rel, walk_source
24
+ from .semver import clean_version, compare_versions, range_blocks_latest
25
+
26
+ PY_EXTS = (".py",)
27
+ NODE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
28
+
29
+ _MONEY_CONTEXT = re.compile(r"(cost|price|prices|money|usd|\.estimate\s*\(|Money|Decimal)")
30
+ _INSTRUMENT_RE = re.compile(r"\binstrument\s*\(")
31
+ _USES_CENDOR_RE = re.compile(r"from\s+cendor[.\s]|import\s+cendor|@cendor/")
32
+ _BARE_IMPORT_RE = re.compile(r"(?:^|\n)[ \t]*import[ \t]+cendor[ \t]*(?:#.*)?(?:\n|$)")
33
+ _STRAY_INIT_RE = re.compile(r"(?:^|[\\/])cendor[\\/]__init__\.py$")
34
+
35
+
36
+ @dataclass
37
+ class Finding:
38
+ severity: str # "error" | "warn" | "info" | "ok"
39
+ title: str
40
+ detail: str
41
+ fix: str | None = None
42
+ locations: list[str] = field(default_factory=list)
43
+
44
+
45
+ @dataclass
46
+ class DoctorResult:
47
+ findings: list[Finding]
48
+ exit_code: int
49
+
50
+
51
+ @dataclass
52
+ class _Src:
53
+ py: list[tuple[str, str]] # (path, text)
54
+ node: list[tuple[str, str]]
55
+
56
+
57
+ def _read_all(paths: list[Path]) -> list[tuple[str, str]]:
58
+ out: list[tuple[str, str]] = []
59
+ for p in paths:
60
+ try:
61
+ out.append((str(p), p.read_text(encoding="utf-8", errors="ignore")))
62
+ except OSError:
63
+ continue
64
+ return out
65
+
66
+
67
+ def _index(root: Path) -> _Src:
68
+ return _Src(
69
+ py=_read_all(walk_source(root, PY_EXTS)), node=_read_all(walk_source(root, NODE_EXTS))
70
+ )
71
+
72
+
73
+ def _usage(src: _Src) -> tuple[int, bool]:
74
+ count = 0
75
+ uses = False
76
+ for _, text in src.py + src.node:
77
+ count += len(_INSTRUMENT_RE.findall(text))
78
+ if _USES_CENDOR_RE.search(text):
79
+ uses = True
80
+ return count, uses
81
+
82
+
83
+ def _check_stray_init(root: Path, src: _Src, out: list[Finding]) -> None:
84
+ stray = [rel(root, Path(p)) for p, _ in src.py if _STRAY_INIT_RE.search(p)]
85
+ if stray:
86
+ out.append(
87
+ Finding(
88
+ "error",
89
+ "A top-level cendor/__init__.py exists",
90
+ "`cendor` is a PEP 420 namespace package. A top-level `cendor/__init__.py` in your own "
91
+ "tree shadows the namespace and breaks every `from cendor.<tool> import ...`.",
92
+ "Delete the file. Each Cendor distribution owns only cendor/<tool>/, never cendor/__init__.py.",
93
+ stray,
94
+ )
95
+ )
96
+
97
+
98
+ def _check_bare_import(root: Path, src: _Src, out: list[Finding]) -> None:
99
+ hits = [rel(root, Path(p)) for p, text in src.py if _BARE_IMPORT_RE.search(text)]
100
+ if hits:
101
+ out.append(
102
+ Finding(
103
+ "warn",
104
+ "Bare `import cendor`",
105
+ "`cendor` is a namespace with no module body, so `import cendor` imports nothing usable.",
106
+ "Import from the flat path instead, e.g. `from cendor.tokenguard import budget`.",
107
+ hits[:8],
108
+ )
109
+ )
110
+
111
+
112
+ def _check_instrument(count: int, uses: bool, out: list[Finding]) -> None:
113
+ if uses and count == 0:
114
+ out.append(
115
+ Finding(
116
+ "warn",
117
+ "No instrument() call found",
118
+ "Cendor is imported but the provider client is never wrapped, so nothing is observed — "
119
+ "budgets, gating, and audit will all see zero calls.",
120
+ "Wrap the client once: `client = instrument(OpenAI())`.",
121
+ )
122
+ )
123
+
124
+
125
+ def _check_money(root: Path, src: _Src, out: list[Finding]) -> None:
126
+ hits: list[str] = []
127
+ for p, text in src.py:
128
+ for line in text.splitlines():
129
+ if re.search(r"\bfloat\s*\(", line) and _MONEY_CONTEXT.search(line):
130
+ hits.append(rel(root, Path(p)))
131
+ break
132
+ for p, text in src.node:
133
+ for line in text.splitlines():
134
+ if (
135
+ re.search(r"\bNumber\s*\(", line) or ".toNumber()" in line
136
+ ) and _MONEY_CONTEXT.search(line):
137
+ hits.append(rel(root, Path(p)))
138
+ break
139
+ if hits:
140
+ out.append(
141
+ Finding(
142
+ "warn",
143
+ "Money coerced to a float / number",
144
+ "Cost and price values are Decimal / decimal.js on purpose — converting to float/number "
145
+ "reintroduces the rounding error the Decimal type exists to prevent.",
146
+ "Keep money as Decimal; format only at the edge with str().",
147
+ sorted(set(hits))[:8],
148
+ )
149
+ )
150
+
151
+
152
+ def _check_py_providers(detected: Detected, src: _Src, out: list[Finding]) -> None:
153
+ used: set[str] = set()
154
+ for _, text in src.py:
155
+ used |= providers_used_in_source(text, "python")
156
+ for p in sorted(used):
157
+ present = p in detected.declared_providers or provider_installed(p)
158
+ if present:
159
+ continue
160
+ pkg = PYPI_PKG_FOR_PROVIDER.get(p, p)
161
+ extra = SDK_EXTRA_FOR_PROVIDER.get(p)
162
+ fix = (
163
+ f'pip install "cendor-sdk[{extra}]" (or `pip install {pkg}`)'
164
+ if extra
165
+ else f"pip install {pkg}"
166
+ )
167
+ out.append(
168
+ Finding(
169
+ "error",
170
+ f'Provider SDK for "{p}" is used but not installed',
171
+ f"Your code imports the {p} SDK, but it is neither declared in pyproject/requirements "
172
+ "nor importable here. Cendor never pulls a provider SDK for you — it is an optional extra.",
173
+ fix,
174
+ )
175
+ )
176
+
177
+
178
+ def _check_py_versions(detected: Detected, out: list[Finding]) -> None:
179
+ behind: list[str] = []
180
+ for name, ver in detected.installed_pypi.items():
181
+ latest = version_snapshot.PYPI.get(name)
182
+ have = clean_version(ver)
183
+ if latest and have and compare_versions(have, latest) < 0:
184
+ behind.append(f"{name} {have} (installed) < {latest}")
185
+ for name, spec in detected.declared_pypi.items():
186
+ if name in detected.installed_pypi:
187
+ continue
188
+ latest = version_snapshot.PYPI.get(name)
189
+ if latest and range_blocks_latest(spec, latest):
190
+ behind.append(f'{name} "{spec}" excludes {latest}')
191
+ if behind:
192
+ out.append(
193
+ Finding(
194
+ "warn",
195
+ "A Cendor package looks behind the latest release",
196
+ f"An installed or pinned version trails the bundled snapshot (as of {version_snapshot.AS_OF}). "
197
+ "Type Teach and fixes only arrive on upgrade. This is an offline hint — /releases is canonical.",
198
+ 'pip install -U "cendor-sdk" (check https://cendor.ai/releases)',
199
+ behind,
200
+ )
201
+ )
202
+
203
+
204
+ def run_doctor(root: Path) -> DoctorResult:
205
+ root = Path(root)
206
+ detected = detect_project(root)
207
+ src = _index(root)
208
+ count, uses = _usage(src)
209
+ findings: list[Finding] = []
210
+
211
+ _check_stray_init(root, src, findings)
212
+ _check_bare_import(root, src, findings)
213
+ _check_instrument(count, uses, findings)
214
+ _check_money(root, src, findings)
215
+
216
+ if detected.python:
217
+ _check_py_providers(detected, src, findings)
218
+ _check_py_versions(detected, findings)
219
+
220
+ if detected.node:
221
+ findings.append(
222
+ Finding(
223
+ "info",
224
+ "Node project detected",
225
+ "This CLI checks the Python ecosystem exactly. For the npm ecosystem (peer deps, "
226
+ "@cendor/* versions), run `npx @cendor/init doctor` from the same project.",
227
+ )
228
+ )
229
+
230
+ has_cendor = (
231
+ uses
232
+ or bool(detected.installed_pypi)
233
+ or bool(detected.declared_pypi)
234
+ or bool(detected.declared_npm)
235
+ )
236
+ if not has_cendor:
237
+ findings.insert(
238
+ 0,
239
+ Finding(
240
+ "info",
241
+ "No Cendor usage detected",
242
+ "Found no Cendor imports or dependencies in this project — nothing to validate.",
243
+ "Run `uvx cendor-init` to wire Cendor + your AI assistant in one step.",
244
+ ),
245
+ )
246
+ elif not any(f.severity in ("error", "warn") for f in findings):
247
+ findings.append(
248
+ Finding(
249
+ "ok",
250
+ "Wiring looks good",
251
+ f"Cendor usage found{f', instrument() called {count}×' if count else ''}; no problems detected.",
252
+ )
253
+ )
254
+
255
+ exit_code = 1 if any(f.severity == "error" for f in findings) else 0
256
+ return DoctorResult(findings=findings, exit_code=exit_code)
257
+
258
+
259
+ SEVERITY_RANK = {"error": 0, "warn": 1, "info": 2, "ok": 3}