honestcode 0.5.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,304 @@
1
+ """pydantic-ai integration for :mod:`honestcode.guards`.
2
+
3
+ Wraps tool execution so that an effect the framework has already carried out is
4
+ not carried out a second time — including across a crash and restart, which is
5
+ the case `pydantic-ai` cannot cover by itself.
6
+
7
+ Why this is a separate package from ``honestcode.guards``:
8
+
9
+ * ``guards`` is stdlib-only and framework-agnostic. Importing ``pydantic_ai``
10
+ into it would end that, and the isolation rule in ``tests/test_guards.py``
11
+ enforces it.
12
+ * The deferral primitives live in the framework. ``pydantic-ai`` already offers
13
+ in-process deferral (``CallDeferred``, ``ApprovalRequired``) from
14
+ ``before_tool_execute``; those are the right tool for *a human should look at
15
+ this*. This integration is for the different question: *did the outside world
16
+ already change, and I no longer remember*. That question survives a process
17
+ restart, so it needs the durable store.
18
+
19
+ The hook, and why it works
20
+ --------------------------
21
+
22
+ ``wrap_tool_execute`` wraps the whole lifecycle. Returning without calling
23
+ ``handler()`` skips execution entirely and the returned value becomes the tool
24
+ result the model sees — verified against ``pydantic-ai`` 2.55.0: with
25
+ ``handler`` called the tool runs once, with it skipped the tool runs zero times
26
+ and the substitute value reaches the model.
27
+
28
+ That is exactly the shape an idempotency guard needs. What this class adds over
29
+ the framework's own deferral is *memory that outlives the process*.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ from dataclasses import dataclass, field
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ if TYPE_CHECKING:
39
+ from collections.abc import Callable, Mapping
40
+
41
+ from honestcode.guards import (
42
+ EffectClass,
43
+ EffectStore,
44
+ SideEffectGuard,
45
+ classify,
46
+ durable_scope,
47
+ )
48
+ from honestcode.guards.guard import EffectNotRunnable, GuardError
49
+
50
+ try: # pragma: no cover - import guard only
51
+ from pydantic_ai.capabilities import AbstractCapability
52
+ from pydantic_ai.exceptions import ModelRetry
53
+ except ImportError as exc: # pragma: no cover
54
+ raise ImportError(
55
+ "honestcode.integrations.pydantic_ai requires pydantic-ai. "
56
+ "From a checkout, install it with: pip install -e '.[pydantic-ai]'"
57
+ ) from exc
58
+
59
+ if TYPE_CHECKING: # pragma: no cover
60
+ from pydantic_ai.tools import ToolDefinition
61
+
62
+ __all__ = ["EffectGuardCapability", "ChargePolicy", "default_policy"]
63
+
64
+
65
+ def _as_mapping(value: Any) -> dict[str, Any] | None:
66
+ """Coerce tool-call arguments into a mapping, or ``None`` if impossible.
67
+
68
+ ``ToolCallPart.args`` is typed as a dict but arrives as a **JSON string**
69
+ while streaming — which is the normal case for a real provider, not an edge
70
+ case. Treating the string form as unkeyable made the guard pass every real
71
+ call straight through while reporting nothing, so this accepts both.
72
+ """
73
+ if isinstance(value, dict):
74
+ return value
75
+ if isinstance(value, str):
76
+ stripped = value.strip()
77
+ if not stripped:
78
+ return None
79
+ try:
80
+ parsed = json.loads(stripped)
81
+ except (ValueError, TypeError):
82
+ return None
83
+ return parsed if isinstance(parsed, dict) else None
84
+ return None
85
+
86
+
87
+ # ── policy ──────────────────────────────────────────────────────────────────
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class ChargePolicy:
92
+ """Which tool calls count as effects worth guarding, and what may run.
93
+
94
+ A guard that wraps every tool call is noise: ``read_file`` and
95
+ ``list_directory`` are not effects, and recording them grows the store while
96
+ adding nothing. This decides what is worth a durable record.
97
+
98
+ The defaults are deliberately narrow — guard what can hurt, let reads
99
+ through untouched, refuse irreversible calls until someone opts in. Only the
100
+ caller knows which of their tools reach the outside world, so widening this
101
+ is their decision.
102
+ """
103
+
104
+ approve_irreversible: bool = False
105
+ """Whether irreversible effects may run.
106
+
107
+ Defaults to refusing, so the first attempt raises a retry asking for
108
+ approval rather than charging a card. The guard's own default, restated here
109
+ because this is the layer where an application is most likely to want the
110
+ blanket decision.
111
+ """
112
+
113
+ approve: Callable[[str, Mapping[str, Any]], bool] | None = None
114
+ """Optional per-call approval, consulted when ``approve_irreversible`` is
115
+ False. Lets an application decide on the specifics — an order's status, a
116
+ spend limit, an allowlist — instead of approving a whole class. Returning
117
+ True for one call does not approve the next."""
118
+
119
+ guard_classes: frozenset[EffectClass] = frozenset(
120
+ {EffectClass.REVERSIBLE, EffectClass.IRREVERSIBLE}
121
+ )
122
+ """Effect classes that get a durable record. Reads are excluded by default."""
123
+
124
+ classify: Callable[[str, str], EffectClass] = classify
125
+ """How to decide the class. Defaults to the shared action-name table."""
126
+
127
+ read_only_tools: frozenset[str] = frozenset()
128
+ """Tool names the application declares to be reads.
129
+
130
+ The shared table describes generic names; it cannot know an application's
131
+ own vocabulary. Without a declaration here, a tool whose name the table does
132
+ not recognise falls to the conservative default — irreversible — and is then
133
+ refused unless approved, which for a local file read is a false positive
134
+ that costs an approval.
135
+
136
+ Declaring is a deliberate act, and that is the point: only the application
137
+ knows whether `search_docs` hits a local index or a paid API.
138
+ """
139
+
140
+ def applies_to(self, effect_class: EffectClass) -> bool:
141
+ return effect_class in self.guard_classes
142
+
143
+ def class_of(self, tool_name: str, target: str) -> EffectClass:
144
+ """The effect class for a call, honouring declared reads."""
145
+ if tool_name in self.read_only_tools:
146
+ return EffectClass.PURE
147
+ return self.classify(tool_name, target)
148
+
149
+ def approves(self, tool_name: str, args: Mapping[str, Any]) -> bool:
150
+ if self.approve_irreversible:
151
+ return True
152
+ if self.approve is not None:
153
+ return bool(self.approve(tool_name, args))
154
+ return False
155
+
156
+
157
+ def default_policy() -> ChargePolicy:
158
+ return ChargePolicy()
159
+
160
+
161
+ # ── capability ──────────────────────────────────────────────────────────────
162
+
163
+
164
+ @dataclass
165
+ class EffectGuardCapability(AbstractCapability): # type: ignore[misc]
166
+ """Make agent tool calls idempotent across a crash and restart.
167
+
168
+ Usage::
169
+
170
+ from honestcode.guards import EffectStore
171
+ from honestcode.integrations.pydantic_ai import EffectGuardCapability
172
+
173
+ cap = EffectGuardCapability(
174
+ store=EffectStore(".agent/effects.db"),
175
+ run_id="order-4711-fulfilment", # stable across retries
176
+ )
177
+ agent = Agent(model, tools=[charge_card], capabilities=[cap])
178
+
179
+ ``run_id`` is the durability boundary and the field that matters most. It
180
+ must be the identifier the application can reproduce after a restart — a
181
+ workflow run id, a job id, an order id. Passing a fresh id on every attempt
182
+ turns the guard into a very expensive no-op, because a new id means every
183
+ retry looks like a new effect.
184
+ """
185
+
186
+ store: EffectStore
187
+ run_id: str
188
+ policy: ChargePolicy = field(default_factory=default_policy)
189
+
190
+ receipts: dict[str, str] = field(default_factory=dict)
191
+ """Effect id per tool call id, for observability after a run."""
192
+
193
+ unguarded_calls: list[str] = field(default_factory=list)
194
+ """Tool names that passed through because their arguments were unkeyable.
195
+
196
+ Non-empty after a run means the guard did *not* protect those calls. That is
197
+ the failure mode this attribute exists to make visible: the first version of
198
+ this file let every real call through this way and recorded nothing.
199
+ """
200
+
201
+ def __post_init__(self) -> None:
202
+ if not self.run_id:
203
+ raise ValueError("run_id must not be empty — it is the durability boundary")
204
+ self._guard = SideEffectGuard(session_id=f"pydantic-ai:{self.run_id}", store=self.store)
205
+
206
+ # ── the hook ────────────────────────────────────────────────────────────
207
+
208
+ async def wrap_tool_execute(
209
+ self,
210
+ ctx: Any,
211
+ *,
212
+ call: Any,
213
+ tool_def: ToolDefinition,
214
+ args: Any,
215
+ handler: Any,
216
+ ) -> Any:
217
+ tool_name = getattr(call, "tool_name", "") or getattr(tool_def, "name", "")
218
+ call_args = _as_mapping(getattr(call, "args", None))
219
+ if call_args is None:
220
+ # Args are absent or unparseable. Without a mapping we cannot key
221
+ # the effect, and guessing risks two different effects sharing a
222
+ # key, so the call is let through unguarded — but silently, which is
223
+ # exactly how the first version of this file shipped a guard that
224
+ # never ran. Record the skip so it is visible in a test or a log.
225
+ self.unguarded_calls.append(tool_name)
226
+ return await handler(args)
227
+
228
+ target = _target_of(tool_name, call_args)
229
+ effect_class = self.policy.class_of(tool_name, target)
230
+
231
+ if not self.policy.applies_to(effect_class):
232
+ return await handler(args)
233
+
234
+ scope = durable_scope(self.run_id, tool_name, target)
235
+ approved = effect_class is not EffectClass.IRREVERSIBLE or self.policy.approves(
236
+ tool_name, call_args
237
+ )
238
+
239
+ async def call_handler() -> Any:
240
+ return await handler(args)
241
+
242
+ try:
243
+ outcome = await self._guard.arun(
244
+ tool_name,
245
+ target=target,
246
+ payload=call_args,
247
+ effect_class=effect_class,
248
+ approve_irreversible=approved,
249
+ scope=scope,
250
+ # The handler is async, and arun writes the `executing` claim
251
+ # before awaiting it — so a crash while the tool is in flight
252
+ # leaves an unknown outcome rather than looking unstarted.
253
+ execute=call_handler,
254
+ )
255
+ except EffectNotRunnable as exc:
256
+ raise ModelRetry(_stuck_message(exc, tool_name, target)) from exc
257
+ except GuardError as exc:
258
+ raise ModelRetry(_refused_message(exc, tool_name, target)) from exc
259
+
260
+ tool_call_id = getattr(call, "tool_call_id", "")
261
+ if tool_call_id:
262
+ self.receipts[tool_call_id] = outcome.effect_id
263
+
264
+ if outcome.deduplicated:
265
+ return _receipt(tool_name, target, effect_class, outcome)
266
+ return outcome.value
267
+
268
+
269
+ def _target_of(tool_name: str, args: Mapping[str, Any]) -> str:
270
+ """A short, stable description of *what* the effect acts on.
271
+
272
+ Used in the scope and in receipts. Picks the first recognisable identifier
273
+ in the arguments rather than dumping them all, so the scope stays stable
274
+ against irrelevant argument churn.
275
+ """
276
+ for key in ("target", "path", "customer", "customer_id", "order_id", "id", "name"):
277
+ if key in args and isinstance(args[key], str | int):
278
+ return f"{key}={args[key]}"
279
+ return tool_name
280
+
281
+
282
+ def _receipt(tool_name: str, target: str, effect_class: EffectClass, outcome: Any) -> str:
283
+ return (
284
+ f"[effect-guard] {tool_name}({target}) was NOT run again — this "
285
+ f"{effect_class.bare} effect already completed in run "
286
+ f"{outcome.record.effect_id}. The previous result stands. If you need a "
287
+ f"genuinely new effect, change the argument that identifies it."
288
+ )
289
+
290
+
291
+ def _refused_message(exc: GuardError, tool_name: str, target: str) -> str:
292
+ return (
293
+ f"[effect-guard] {tool_name}({target}) was not run: {exc}. "
294
+ f"Resolve the condition and retry, or use a different action."
295
+ )
296
+
297
+
298
+ def _stuck_message(exc: EffectNotRunnable, tool_name: str, target: str) -> str:
299
+ return (
300
+ f"[effect-guard] {tool_name}({target}) has an unknown outcome from an "
301
+ f"interrupted attempt ({exc.status}). It may already have reached the "
302
+ f"outside world. Do not retry it blindly — it needs to be reconciled "
303
+ f"with the external system first."
304
+ )
@@ -0,0 +1,46 @@
1
+ """MCP server integration for HonestCode.
2
+
3
+ Import directly from submodules to avoid loading the MCP server eagerly:
4
+
5
+ from honestcode.mcp.tools import scan_file
6
+ from honestcode.mcp.server import main
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__ = [
12
+ "APIKnowledgeBase",
13
+ "APISignature",
14
+ "check_api",
15
+ "check_symbol",
16
+ "choose_tool",
17
+ "execute_code",
18
+ "explore_call_graph",
19
+ "find_dead_code",
20
+ "find_similar_code",
21
+ "get_project_stats",
22
+ "index_project",
23
+ "load_package_apis",
24
+ "load_project_deps",
25
+ "main",
26
+ "mcp",
27
+ "scan_file",
28
+ "search_code",
29
+ "validate_types",
30
+ ]
31
+
32
+
33
+ def __getattr__(name: str):
34
+ if name in ("main", "mcp"):
35
+ from honestcode.mcp.server import main, mcp
36
+
37
+ return {"main": main, "mcp": mcp}[name]
38
+ if name in ("APIKnowledgeBase", "APISignature"):
39
+ from honestcode.mcp.knowledge_base import APIKnowledgeBase, APISignature
40
+
41
+ return {"APIKnowledgeBase": APIKnowledgeBase, "APISignature": APISignature}[name]
42
+ if name in __all__:
43
+ from honestcode.mcp import tools
44
+
45
+ return getattr(tools, name)
46
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,290 @@
1
+ """
2
+ Knowledge base of dependency APIs for HonestCode.
3
+
4
+ Loads API signatures from installed packages via ``pydoc`` / AST inspection
5
+ and stores them for fast lookup during hallucination checks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import logging
12
+ import pkgutil
13
+ import re
14
+ import threading
15
+ from dataclasses import dataclass
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ __all__ = ["APISignature", "APIKnowledgeBase", "resolve_import_names"]
20
+
21
+ # Fallback for environments where importlib.metadata has no answer. Maps
22
+ # PyPI distribution name -> import name for the common offenders whose PyPI
23
+ # and import names differ; without this, ``pip install PyYAML`` would never
24
+ # load ``yaml`` APIs and every ``from yaml import safe_load`` would be
25
+ # reported as an undefined symbol.
26
+ _FALLBACK_DIST_TO_IMPORT: dict[str, str] = {
27
+ "pillow": "PIL",
28
+ "scikit-learn": "sklearn",
29
+ "scikit-image": "skimage",
30
+ "beautifulsoup4": "bs4",
31
+ "python-dateutil": "dateutil",
32
+ "opencv-python": "cv2",
33
+ "pyyaml": "yaml",
34
+ "pycryptodome": "Crypto",
35
+ "python-dotenv": "dotenv",
36
+ "gitpython": "git",
37
+ "pyserial": "serial",
38
+ "msgpack-python": "msgpack",
39
+ # The slim distribution and its import name differ by a hyphen.
40
+ "pydantic-ai-slim": "pydantic_ai",
41
+ "pydantic-ai": "pydantic_ai",
42
+ }
43
+
44
+ _import_to_distributions: dict[str, list[str]] | None = None
45
+
46
+
47
+ def _normalise_dist(name: str) -> str:
48
+ """Normalize a distribution name per PEP 503 (``PyYAML`` -> ``pyyaml``)."""
49
+ return re.sub(r"[-_.]+", "-", name).lower()
50
+
51
+
52
+ def _import_distribution_map() -> dict[str, list[str]]:
53
+ global _import_to_distributions
54
+ if _import_to_distributions is None:
55
+ try:
56
+ from importlib.metadata import packages_distributions
57
+
58
+ _import_to_distributions = packages_distributions()
59
+ except Exception as e: # noqa: BLE001
60
+ logger.debug("packages_distributions unavailable: %s", e)
61
+ _import_to_distributions = {}
62
+ return _import_to_distributions
63
+
64
+
65
+ def resolve_import_names(package_name: str) -> set[str]:
66
+ """Return import-name candidates for a PyPI distribution name.
67
+
68
+ ``load_package("PyYAML")`` must import ``yaml``, not ``PyYAML``. Candidates
69
+ come from :func:`importlib.metadata.packages_distributions` (import name ->
70
+ installed distributions, reversed here), with a static fallback table for
71
+ the well-known mismatches.
72
+ """
73
+ candidates = {package_name, package_name.replace("-", "_")}
74
+ norm = _normalise_dist(package_name)
75
+ for import_name, dists in _import_distribution_map().items():
76
+ if any(_normalise_dist(d) == norm for d in dists):
77
+ candidates.add(import_name)
78
+ for dist, import_name in _FALLBACK_DIST_TO_IMPORT.items():
79
+ if _normalise_dist(dist) == norm:
80
+ candidates.add(import_name)
81
+ return candidates
82
+
83
+
84
+ @dataclass
85
+ class APISignature:
86
+ name: str
87
+ signature: str = ""
88
+ doc: str = ""
89
+ module: str = ""
90
+
91
+
92
+ class APIKnowledgeBase:
93
+ """Lightweight cache of public API signatures for known packages."""
94
+
95
+ def __init__(self):
96
+ self._lock = threading.RLock()
97
+ self.apis: dict[str, APISignature] = {}
98
+ self._loaded_packages: set[str] = set()
99
+ # Import-name roots seen so far — including packages that were
100
+ # *declared* (in requirements.txt / pyproject.toml) but failed to
101
+ # import. Imports from declared roots stay trusted even when the
102
+ # package is not installed, so missing installs never turn into
103
+ # false "undefined symbol" findings.
104
+ self._roots: set[str] = set()
105
+ # Module names whose member surface was actually enumerated (the
106
+ # package root and the submodules that imported successfully). Only
107
+ # these can be used to decide that ``mod.attr`` is *missing* — a
108
+ # declared-but-uninstalled package, or a submodule we never walked
109
+ # into, knows nothing about its members, so member checks must stay
110
+ # silent for them rather than assume an empty surface.
111
+ self._enumerated: set[str] = set()
112
+
113
+ def declare_package(self, package_name: str) -> None:
114
+ """Register a declared dependency without importing it.
115
+
116
+ Imports from a declared package are trusted (the package is part of
117
+ the project's contract), but no member surface exists, so invented-API
118
+ checks stay silent for it — "not in the index" is not evidence of
119
+ absence when the package was never imported.
120
+ """
121
+ import_names = resolve_import_names(package_name)
122
+ with self._lock:
123
+ self._roots.update(n.split(".")[0] for n in import_names)
124
+
125
+ def load_package(self, package_name: str) -> int:
126
+ """Extract public API signatures from an installed package.
127
+
128
+ *package_name* may be a PyPI distribution name (``PyYAML``) or an
129
+ import name (``yaml``); both resolve to the same module.
130
+
131
+ Imports happen outside the lock to avoid blocking concurrent readers,
132
+ but the shared cache is updated under the lock.
133
+ """
134
+ with self._lock:
135
+ if package_name in self._loaded_packages:
136
+ return sum(1 for v in self.apis.values() if v.module.startswith(package_name))
137
+
138
+ import_names = resolve_import_names(package_name)
139
+ with self._lock:
140
+ self._loaded_packages.add(package_name)
141
+ self._roots.update(n.split(".")[0] for n in import_names)
142
+
143
+ mod = None
144
+ last_error: Exception | None = None
145
+
146
+ def _candidate_rank(name: str) -> tuple:
147
+ # Order: the exact requested import name first (authoritative),
148
+ # then its underscore spelling, then other public candidates
149
+ # (e.g. "yaml" for "PyYAML"), private C-extension modules last
150
+ # (importing "_yaml" would key the whole API base under "_yaml"
151
+ # and make every "yaml.*" call look invented).
152
+ return (
153
+ name != package_name,
154
+ name != package_name.replace("-", "_"),
155
+ name.startswith("_"),
156
+ name,
157
+ )
158
+
159
+ for candidate in sorted(import_names, key=_candidate_rank):
160
+ try:
161
+ mod = importlib.import_module(candidate)
162
+ break
163
+ except Exception as e: # noqa: BLE001
164
+ last_error = e
165
+ if mod is None:
166
+ logger.warning(
167
+ "Cannot import %s (tried %s): %s",
168
+ package_name,
169
+ sorted(import_names),
170
+ last_error,
171
+ )
172
+ with self._lock:
173
+ # Allow a retry once the package is installed, but keep the
174
+ # declared root so imports from it are still trusted.
175
+ self._loaded_packages.discard(package_name)
176
+ return 0
177
+
178
+ import_root = mod.__name__.split(".")[0]
179
+ new_apis: dict[str, APISignature] = {}
180
+ seen: set[str] = set()
181
+ enumerated: set[str] = {mod.__name__}
182
+ count = self._collect_from_module(mod, mod.__name__, mod.__name__, new_apis, seen)
183
+
184
+ # Recurse into submodules (one level) to enrich the base.
185
+ try:
186
+ paths = getattr(mod, "__path__", None)
187
+ if paths is not None:
188
+ for info in pkgutil.iter_modules(paths):
189
+ if info.name.startswith("_"):
190
+ continue
191
+ sub = f"{import_root}.{info.name}"
192
+ try:
193
+ submod = importlib.import_module(sub)
194
+ except Exception as e: # noqa: BLE001
195
+ logger.debug("Cannot import submodule %s: %s", sub, e)
196
+ continue
197
+ enumerated.add(sub)
198
+ count += self._collect_from_module(submod, sub, sub, new_apis, seen)
199
+ except Exception as e: # noqa: BLE001
200
+ logger.warning("Failed to enumerate submodules of %s: %s", package_name, e)
201
+
202
+ with self._lock:
203
+ self.apis.update(new_apis)
204
+ self._loaded_packages.add(import_root)
205
+ self._enumerated.update(enumerated)
206
+
207
+ logger.info("Loaded %d APIs from %s", count, package_name)
208
+ return count
209
+
210
+ def _collect_from_module(
211
+ self,
212
+ mod,
213
+ module_name: str,
214
+ api_module: str,
215
+ out: dict[str, APISignature],
216
+ seen: set[str],
217
+ ) -> int:
218
+ """Collect public attributes from a module into *out*.
219
+
220
+ Returns the number of newly added signatures.
221
+ """
222
+ count = 0
223
+ try:
224
+ names = dir(mod)
225
+ except Exception as e: # noqa: BLE001
226
+ logger.warning("dir(%s) failed: %s", module_name, e)
227
+ return 0
228
+
229
+ for name in names:
230
+ if name.startswith("_"):
231
+ continue
232
+ full = f"{api_module}.{name}"
233
+ if full in seen:
234
+ continue
235
+ seen.add(full)
236
+ try:
237
+ obj = getattr(mod, name)
238
+ sig = self._signature(obj)
239
+ except Exception: # noqa: BLE001
240
+ sig = ""
241
+ out[full] = APISignature(name=full, signature=sig, module=api_module)
242
+ count += 1
243
+ return count
244
+
245
+ @staticmethod
246
+ def _signature(obj) -> str:
247
+ try:
248
+ import inspect
249
+
250
+ return str(inspect.signature(obj))
251
+ except (TypeError, ValueError):
252
+ return ""
253
+
254
+ def get(self, name: str) -> APISignature | None:
255
+ with self._lock:
256
+ return self.apis.get(name)
257
+
258
+ def has(self, name: str) -> bool:
259
+ with self._lock:
260
+ return name in self.apis
261
+
262
+ def search(self, prefix: str, limit: int = 10) -> list[str]:
263
+ with self._lock:
264
+ return [k for k in self.apis if k.startswith(prefix)][:limit]
265
+
266
+ def all_names(self) -> set[str]:
267
+ with self._lock:
268
+ return set(self.apis.keys())
269
+
270
+ def roots(self) -> set[str]:
271
+ """Import-name roots this KB knows about (loaded or declared)."""
272
+ with self._lock:
273
+ return set(self._roots)
274
+
275
+ def enumerated_modules(self) -> set[str]:
276
+ """Module names whose members were actually enumerated.
277
+
278
+ Member checks (``mod.attr`` does not exist) are only sound for these
279
+ modules; everything else must stay silent — see the field docstring.
280
+ """
281
+ with self._lock:
282
+ return set(self._enumerated)
283
+
284
+ def reset(self) -> None:
285
+ """Drop every loaded API and root (used by tests and benchmarks)."""
286
+ with self._lock:
287
+ self.apis.clear()
288
+ self._loaded_packages.clear()
289
+ self._roots.clear()
290
+ self._enumerated.clear()