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,195 @@
1
+ """Agent-facing verification protocol.
2
+
3
+ HonestCode's job is not to print lint warnings — it is to hand a coding agent
4
+ a piece of **evidence** it can act on without a second round-trip. Every
5
+ finding is therefore emitted in a fixed, machine-consumable shape:
6
+
7
+ .. code-block:: json
8
+
9
+ {
10
+ "status": "fail",
11
+ "file": "auth/client.py",
12
+ "line": 42,
13
+ "kind": "invented_api",
14
+ "symbol": "refresh_token",
15
+ "owner": "UserClient",
16
+ "message": "UserClient.refresh_token() does not exist.",
17
+ "evidence": {"available_methods": ["refresh", "refresh_access_token"]},
18
+ "confidence": "deterministic",
19
+ "action": "revise"
20
+ }
21
+
22
+ Two invariants keep the protocol honest:
23
+
24
+ * ``confidence: deterministic`` is used only when the verifier could prove the
25
+ claim from the repository alone (the owning class was resolved and its
26
+ member surface is complete). Findings that would have to be inferred through
27
+ an unresolved base class are **not reported at all** — absence cannot be
28
+ proven there, and real-world testing showed that guessing produces
29
+ misleadingly confident false positives (v0.4.0).
30
+ * ``action`` tells the agent what to do next, so it does not have to guess
31
+ whether a finding is fatal or advisory.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from dataclasses import dataclass, field
37
+ from typing import Any
38
+
39
+ __all__ = [
40
+ "Finding",
41
+ "STATUS_FAIL",
42
+ "STATUS_PASS",
43
+ "build_report",
44
+ "render_text",
45
+ ]
46
+
47
+ STATUS_PASS = "pass"
48
+ STATUS_FAIL = "fail"
49
+
50
+ # Finding kinds, ordered by how much an agent should care.
51
+ KIND_SYNTAX_ERROR = "syntax_error"
52
+ KIND_INVENTED_API = "invented_api"
53
+ KIND_WRONG_CALL = "wrong_call"
54
+ KIND_UNDEFINED_SYMBOL = "undefined_symbol"
55
+
56
+ _KIND_ORDER = {
57
+ KIND_SYNTAX_ERROR: 0,
58
+ KIND_INVENTED_API: 1,
59
+ KIND_WRONG_CALL: 2,
60
+ KIND_UNDEFINED_SYMBOL: 3,
61
+ }
62
+
63
+ CONFIDENCE_DETERMINISTIC = "deterministic"
64
+ CONFIDENCE_HIGH = "high"
65
+ #: Heuristic (non-Python) findings: a pointer to inspect, never a verdict.
66
+ CONFIDENCE_LOW = "low"
67
+
68
+ ACTION_REVISE = "revise"
69
+ ACTION_INSPECT = "inspect"
70
+
71
+ # Backwards-compatible ``type`` values for the legacy ``issues`` list.
72
+ _LEGACY_TYPE = {
73
+ KIND_SYNTAX_ERROR: "syntax_error",
74
+ KIND_INVENTED_API: "invented_api",
75
+ KIND_WRONG_CALL: "wrong_call",
76
+ KIND_UNDEFINED_SYMBOL: "undefined_call",
77
+ }
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Finding:
82
+ """A single verification failure with enough evidence to fix it."""
83
+
84
+ file: str
85
+ line: int
86
+ kind: str
87
+ symbol: str
88
+ message: str
89
+ owner: str | None = None
90
+ evidence: dict[str, Any] = field(default_factory=dict)
91
+ confidence: str = CONFIDENCE_DETERMINISTIC
92
+ action: str = ACTION_REVISE
93
+
94
+ def to_dict(self) -> dict[str, Any]:
95
+ """Return the agent-facing protocol representation."""
96
+ out: dict[str, Any] = {
97
+ "file": self.file,
98
+ "line": self.line,
99
+ "kind": self.kind,
100
+ "symbol": self.symbol,
101
+ "message": self.message,
102
+ "confidence": self.confidence,
103
+ "action": self.action,
104
+ }
105
+ if self.owner is not None:
106
+ out["owner"] = self.owner
107
+ if self.evidence:
108
+ out["evidence"] = self.evidence
109
+ return out
110
+
111
+ def legacy(self) -> dict[str, Any]:
112
+ """Return the pre-0.3 ``issues`` shape (``type``/``name``/``line``)."""
113
+ return {
114
+ "type": _LEGACY_TYPE.get(self.kind, self.kind),
115
+ "name": self.symbol,
116
+ "line": self.line,
117
+ "kind": self.kind,
118
+ "owner": self.owner,
119
+ "evidence": dict(self.evidence),
120
+ "confidence": self.confidence,
121
+ "action": self.action,
122
+ "message": self.message,
123
+ }
124
+
125
+
126
+ def build_report(
127
+ file: str,
128
+ findings: list[Finding],
129
+ *,
130
+ checked: int = 0,
131
+ truncated: bool = False,
132
+ ) -> dict[str, Any]:
133
+ """Wrap *findings* into a full verification report."""
134
+ ordered = sorted(findings, key=lambda f: (_KIND_ORDER.get(f.kind, 99), f.line, f.symbol))
135
+ by_kind: dict[str, int] = {}
136
+ for f in ordered:
137
+ by_kind[f.kind] = by_kind.get(f.kind, 0) + 1
138
+ report: dict[str, Any] = {
139
+ "success": True,
140
+ "status": STATUS_FAIL if ordered else STATUS_PASS,
141
+ "file": file,
142
+ "findings": [f.to_dict() for f in ordered],
143
+ "summary": {
144
+ "total": len(ordered),
145
+ "by_kind": by_kind,
146
+ "call_sites_checked": checked,
147
+ },
148
+ # Legacy shape kept so existing clients/CI keep working unchanged.
149
+ "issues": [f.legacy() for f in ordered],
150
+ }
151
+ if truncated:
152
+ report["truncated"] = True
153
+ return report
154
+
155
+
156
+ def render_text(report: dict[str, Any], *, max_methods: int = 8) -> str:
157
+ """Render a report as the compact block an agent (or a human) reads.
158
+
159
+ This is deliberately terse: an agent pays tokens for every character, and
160
+ a human scanning a GIF should see the verdict in under a second.
161
+ """
162
+ findings = report.get("findings") or []
163
+ if not findings:
164
+ checked = report.get("summary", {}).get("call_sites_checked", 0)
165
+ return (
166
+ f"✓ verification passed — {report.get('file', '')}\n"
167
+ f" {checked} call site(s) grounded in the repository · deterministic"
168
+ )
169
+
170
+ head = f"✗ verification failed — {len(findings)} issue(s) in {report.get('file', '')}"
171
+ lines = [head]
172
+ for f in findings:
173
+ lines.append("")
174
+ location = f"{f.get('file', '')}:{f.get('line', 0)}"
175
+ lines.append(f" [{f.get('kind')}] {location}")
176
+ lines.append(f" {f.get('message')}")
177
+ evidence = f.get("evidence") or {}
178
+ suggestion = evidence.get("did_you_mean")
179
+ if suggestion:
180
+ lines.append(f" did you mean: {suggestion}()?")
181
+ available = evidence.get("available_methods") or []
182
+ if available:
183
+ lines.append(" available methods:")
184
+ for name in available[:max_methods]:
185
+ lines.append(f" - {name}()")
186
+ hidden = len(available) - max_methods
187
+ if hidden > 0:
188
+ lines.append(f" … and {hidden} more")
189
+ expected = evidence.get("expected")
190
+ actual = evidence.get("actual")
191
+ if expected is not None and actual is not None:
192
+ lines.append(f" expected: {expected}")
193
+ lines.append(f" actual: {actual}")
194
+ lines.append(f" confidence: {f.get('confidence')} · action: {f.get('action')}")
195
+ return "\n".join(lines)
@@ -0,0 +1,497 @@
1
+ """Class member surfaces — the ground truth behind ``invented_api``.
2
+
3
+ To claim "``UserClient.refresh_token()`` does not exist" you must first know
4
+ *every* member ``UserClient`` actually has. This module builds that surface
5
+ from the AST, and — critically — records when it **cannot**:
6
+
7
+ * ``dynamic`` the class defines ``__getattr__`` / ``__getattribute__``, so
8
+ any attribute lookup may succeed at runtime.
9
+ * incomplete a base class could not be resolved, so inherited members are
10
+ unknown.
11
+
12
+ Both cases force the verifier to stay quiet (or downgrade its confidence)
13
+ instead of guessing. A verification layer that cries wolf is worse than no
14
+ verification layer at all.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+ import os
21
+ import threading
22
+ from collections import OrderedDict
23
+ from dataclasses import dataclass, field
24
+ from typing import TYPE_CHECKING
25
+
26
+ from honestcode.structure.utils import annotation_name, call_name, read_python_source
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Iterator
30
+ from pathlib import Path
31
+
32
+ __all__ = [
33
+ "MethodInfo",
34
+ "ClassSurface",
35
+ "FunctionInfo",
36
+ "extract_file_surfaces",
37
+ "imported_symbols_of",
38
+ "load_file_facts",
39
+ ]
40
+
41
+ # Bases whose member set is empty and safe to treat as fully resolved.
42
+ EMPTY_BASES = frozenset({"object"})
43
+
44
+ # Decorators that do not change the callable's runtime signature.
45
+ ARITY_SAFE_DECORATORS = frozenset(
46
+ {
47
+ "staticmethod",
48
+ "classmethod",
49
+ "property",
50
+ "cached_property",
51
+ "functools.cached_property",
52
+ "functools.wraps",
53
+ "wraps",
54
+ "override",
55
+ "typing.override",
56
+ }
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class MethodInfo:
62
+ """Callable signature facts needed for an argument-count check."""
63
+
64
+ name: str
65
+ min_args: int # required args a caller must pass
66
+ max_args: int | None # None = unbounded (*args present)
67
+ keywords: frozenset[str] = frozenset()
68
+ has_varargs: bool = False
69
+ has_kwargs: bool = False
70
+ decorators: frozenset[str] = frozenset()
71
+ is_property: bool = False
72
+ # Required parameter names, so an arity check can count parameters passed
73
+ # by keyword — ``f(a, b)`` called as ``f(a=1, b=2)`` is valid Python, and
74
+ # keyword-call style dominates modern code.
75
+ required_positional: tuple[str, ...] = ()
76
+ required_keywords: frozenset[str] = frozenset()
77
+
78
+ @property
79
+ def decorated(self) -> bool:
80
+ return bool(self.decorators)
81
+
82
+ @property
83
+ def arity_trustworthy(self) -> bool:
84
+ """True when no decorator could have rewritten the signature."""
85
+ return all(d.split(".")[-1] in ARITY_SAFE_DECORATORS for d in self.decorators)
86
+
87
+ def describe(self) -> str:
88
+ if self.max_args is None:
89
+ return f"at least {self.min_args} argument(s)"
90
+ if self.min_args == self.max_args:
91
+ return f"exactly {self.min_args} argument(s)"
92
+ return f"{self.min_args}-{self.max_args} argument(s)"
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class FunctionInfo:
97
+ """A module-level (or nested) function's callable facts."""
98
+
99
+ qname: str
100
+ method: MethodInfo
101
+
102
+
103
+ @dataclass
104
+ class ClassSurface:
105
+ """Everything statically knowable about a class's public surface."""
106
+
107
+ qname: str
108
+ file: str
109
+ line: int
110
+ methods: dict[str, MethodInfo] = field(default_factory=dict)
111
+ attributes: set[str] = field(default_factory=set)
112
+ instance_attrs: dict[str, str] = field(default_factory=dict)
113
+ bases: list[str] = field(default_factory=list)
114
+ # Syntactic bases whose name could not be extracted (e.g. ``*bases``):
115
+ # they may provide members, so the surface is not complete.
116
+ unknown_bases: int = 0
117
+ # ``class C(metaclass=Meta)`` — a metaclass may inject arbitrary members.
118
+ has_metaclass: bool = False
119
+ dynamic: bool = False
120
+ decorated: bool = False
121
+
122
+ @property
123
+ def members(self) -> set[str]:
124
+ return set(self.methods) | self.attributes | set(self.instance_attrs)
125
+
126
+
127
+ def _decorator_name(node: ast.expr) -> str:
128
+ """Best-effort dotted name for a decorator expression."""
129
+ if isinstance(node, ast.Name):
130
+ return node.id
131
+ if isinstance(node, ast.Attribute):
132
+ parts: list[str] = []
133
+ cur: ast.expr = node
134
+ while isinstance(cur, ast.Attribute):
135
+ parts.append(cur.attr)
136
+ cur = cur.value
137
+ if isinstance(cur, ast.Name):
138
+ parts.append(cur.id)
139
+ return ".".join(reversed(parts))
140
+ if isinstance(node, ast.Call):
141
+ return _decorator_name(node.func)
142
+ return ""
143
+
144
+
145
+ def _arity(
146
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
147
+ *,
148
+ bound_params: int,
149
+ ) -> MethodInfo:
150
+ """Compute callable facts for *node*, dropping *bound_params* leading args.
151
+
152
+ ``@staticmethod`` methods take no implicit first parameter, so the drop is
153
+ forced to zero for them — treating ``self`` as bound used to shift every
154
+ static method's arity by one and flag valid calls
155
+ (``RequestEncodingMixin._encode_params(data)`` reported "expected 0").
156
+ ``@classmethod`` keeps the drop (``cls`` is bound).
157
+ """
158
+ a = node.args
159
+ names = [_decorator_name(d) for d in node.decorator_list]
160
+ short_decorators = {n.split(".")[-1] for n in names if n}
161
+ if "staticmethod" in short_decorators:
162
+ bound_params = 0
163
+
164
+ pos = list(getattr(a, "posonlyargs", []) or []) + list(a.args)
165
+ n_defaults = len(a.defaults)
166
+ required_pos = len(pos) - n_defaults
167
+ kwonly = list(a.kwonlyargs)
168
+ required_kw_names = frozenset(
169
+ arg.arg for arg, dflt in zip(kwonly, a.kw_defaults, strict=False) if dflt is None
170
+ )
171
+
172
+ drop = min(bound_params, len(pos))
173
+ after_drop = pos[drop:]
174
+ required_positional = tuple(x.arg for x in after_drop[: max(0, required_pos - drop)])
175
+ min_args = len(required_positional) + len(required_kw_names)
176
+ max_args = None if a.vararg is not None else max(0, len(pos) - drop) + len(kwonly)
177
+ keywords = frozenset([x.arg for x in pos[drop:]] + [x.arg for x in kwonly])
178
+
179
+ return MethodInfo(
180
+ name=node.name,
181
+ min_args=min_args,
182
+ max_args=max_args,
183
+ keywords=keywords,
184
+ has_varargs=a.vararg is not None,
185
+ has_kwargs=a.kwarg is not None,
186
+ decorators=frozenset(n for n in names if n),
187
+ is_property=any(n.split(".")[-1] in ("property", "cached_property") for n in names),
188
+ required_positional=required_positional,
189
+ required_keywords=required_kw_names,
190
+ )
191
+
192
+
193
+ # ``annotation_name`` moved to ``structure.utils`` (the symbol index needs it
194
+ # too); the import above re-exports it so existing importers keep working.
195
+
196
+
197
+ def _iter_scope(node: ast.AST, prefix: str) -> Iterator[tuple[ast.AST, str]]:
198
+ """Yield (node, dotted-name-prefix) for every class scope under *node*."""
199
+ for child in ast.iter_child_nodes(node):
200
+ if isinstance(child, ast.ClassDef):
201
+ name = f"{prefix}.{child.name}" if prefix else child.name
202
+ yield child, name
203
+ yield from _iter_scope(child, name)
204
+ elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
205
+ # Functions can contain locally-defined classes; keep walking.
206
+ yield from _iter_scope(child, prefix)
207
+
208
+
209
+ def _attribute_root(node: ast.Attribute) -> str | None:
210
+ """Return the leftmost name of an attribute chain."""
211
+ current: ast.expr = node.value
212
+ while isinstance(current, ast.Attribute):
213
+ current = current.value
214
+ return current.id if isinstance(current, ast.Name) else None
215
+
216
+
217
+ def _collect_instance_attrs(node: ast.ClassDef) -> dict[str, str]:
218
+ """Collect every ``self.x`` / ``cls.x`` attribute of the class.
219
+
220
+ A name is a member whether or not its type can be inferred: the verifier
221
+ may not know ``self.cb``'s type, but it must not claim that ``C.cb`` does
222
+ not exist (``self.cb = cb`` then ``self.cb(1)`` used to be reported as an
223
+ invented API — in this project's own ``graph/watcher.py``, no less).
224
+ Unknown types map to the empty string, which the receiver-chain resolver
225
+ treats as "cannot continue" (silence), never as a claim.
226
+ """
227
+ attrs: dict[str, str] = {}
228
+
229
+ def record(name: str, inferred: str | None) -> None:
230
+ if inferred:
231
+ attrs[name] = inferred
232
+ else:
233
+ attrs.setdefault(name, "")
234
+
235
+ for method in ast.walk(node):
236
+ if not isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)):
237
+ continue
238
+ for stmt in ast.walk(method):
239
+ inferred: str | None = None
240
+ targets: list[ast.expr] = []
241
+ if isinstance(stmt, ast.Assign):
242
+ targets = list(stmt.targets)
243
+ if isinstance(stmt.value, ast.Call):
244
+ inferred = call_name(stmt.value.func)
245
+ elif isinstance(stmt, ast.AnnAssign):
246
+ targets = [stmt.target]
247
+ if stmt.annotation is not None:
248
+ inferred = annotation_name(stmt.annotation)
249
+ if inferred is None and isinstance(stmt.value, ast.Call):
250
+ inferred = call_name(stmt.value.func)
251
+ elif isinstance(stmt, ast.AugAssign):
252
+ targets = [stmt.target]
253
+ elif (
254
+ isinstance(stmt, ast.Call)
255
+ and isinstance(stmt.func, ast.Name)
256
+ and stmt.func.id == "setattr"
257
+ and len(stmt.args) >= 2
258
+ and isinstance(stmt.args[0], ast.Name)
259
+ and stmt.args[0].id in ("self", "cls")
260
+ and isinstance(stmt.args[1], ast.Constant)
261
+ and isinstance(stmt.args[1].value, str)
262
+ ):
263
+ record(stmt.args[1].value, None)
264
+ continue
265
+ for target in targets:
266
+ if isinstance(target, ast.Attribute):
267
+ root = _attribute_root(target)
268
+ if root in ("self", "cls"):
269
+ record(target.attr, inferred)
270
+ return attrs
271
+
272
+
273
+ def _iter_class_body_stmts(stmts: list[ast.stmt]) -> Iterator[ast.stmt]:
274
+ """Yield class-body statements in document order, descending into the
275
+ bodies of conditionals.
276
+
277
+ Methods defined under ``if sys.version_info >= ...:`` or a
278
+ ``try/except ImportError:`` compat fallback are real members; walking only
279
+ the class body's direct children missed them and reported valid code as an
280
+ invented API. Nested functions and classes are yielded as-is and not
281
+ descended into — they are separate scopes.
282
+ """
283
+ for stmt in stmts:
284
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
285
+ yield stmt
286
+ continue
287
+ if isinstance(
288
+ stmt, (ast.If, ast.Try, ast.With, ast.AsyncWith, ast.For, ast.AsyncFor, ast.While)
289
+ ):
290
+ yield from _iter_class_body_stmts(stmt.body)
291
+ for handler in getattr(stmt, "handlers", ()) or ():
292
+ yield from _iter_class_body_stmts(handler.body)
293
+ yield from _iter_class_body_stmts(getattr(stmt, "orelse", ()) or ())
294
+ yield from _iter_class_body_stmts(getattr(stmt, "finalbody", ()) or ())
295
+ continue
296
+ yield stmt
297
+
298
+
299
+ def extract_file_surfaces(
300
+ tree: ast.AST,
301
+ *,
302
+ module: str,
303
+ file_label: str,
304
+ ) -> tuple[dict[str, ClassSurface], dict[str, MethodInfo]]:
305
+ """Extract every class surface and module-level function from *tree*.
306
+
307
+ Returns ``(classes, functions)`` keyed by module-qualified name. Nested
308
+ classes get dotted names (``mod.Outer.Inner``), matching how the symbol
309
+ index names them.
310
+ """
311
+ classes: dict[str, ClassSurface] = {}
312
+ functions: dict[str, MethodInfo] = {}
313
+
314
+ def qualified(local: str) -> str:
315
+ return f"{module}.{local}" if module else local
316
+
317
+ for node, prefix in _iter_scope(tree, ""):
318
+ if not isinstance(node, ast.ClassDef):
319
+ continue
320
+ qname = qualified(prefix)
321
+ extracted_bases = [annotation_name(x) for x in node.bases]
322
+ surface = ClassSurface(
323
+ qname=qname,
324
+ file=file_label,
325
+ line=node.lineno,
326
+ bases=[b for b in extracted_bases if b],
327
+ unknown_bases=sum(1 for b in extracted_bases if not b),
328
+ has_metaclass=bool(node.keywords),
329
+ decorated=bool(node.decorator_list),
330
+ instance_attrs=_collect_instance_attrs(node),
331
+ )
332
+ for item in _iter_class_body_stmts(node.body):
333
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
334
+ info = _arity(item, bound_params=1)
335
+ surface.methods[item.name] = info
336
+ if item.name in ("__getattr__", "__getattribute__"):
337
+ surface.dynamic = True
338
+ elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
339
+ surface.attributes.add(item.target.id)
340
+ elif isinstance(item, ast.Assign):
341
+ for target in item.targets:
342
+ if isinstance(target, ast.Name):
343
+ surface.attributes.add(target.id)
344
+ elif isinstance(item, (ast.Import, ast.ImportFrom)):
345
+ # A class-body import binds the name in the class namespace
346
+ # (``try: from _json import encode_basestring as encode``).
347
+ for alias in item.names:
348
+ local = alias.asname or alias.name
349
+ if local and local != "*":
350
+ surface.attributes.add(local)
351
+ classes[qname] = surface
352
+
353
+ # Module-level statements can add members no class-body scan can see:
354
+ # ``setattr(Cls, "name", ...)``, and plain ``Cls.name = ...`` (the
355
+ # monkey-patch/mixin-registration style used by real projects). Treat both
356
+ # as attributes so an existing member is never claimed absent.
357
+ for stmt in _iter_class_body_stmts(list(getattr(tree, "body", ()))):
358
+ if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
359
+ call = stmt.value
360
+ if not (isinstance(call.func, ast.Name) and call.func.id == "setattr"):
361
+ continue
362
+ if len(call.args) < 2:
363
+ continue
364
+ target, name_arg = call.args[0], call.args[1]
365
+ if not (
366
+ isinstance(target, ast.Name)
367
+ and isinstance(name_arg, ast.Constant)
368
+ and isinstance(name_arg.value, str)
369
+ ):
370
+ continue
371
+ cls_surface = classes.get(qualified(target.id))
372
+ if cls_surface is not None:
373
+ cls_surface.attributes.add(name_arg.value)
374
+ continue
375
+ targets: list[ast.expr] = []
376
+ if isinstance(stmt, ast.Assign):
377
+ targets = list(stmt.targets)
378
+ elif isinstance(stmt, ast.AnnAssign) and stmt.target is not None:
379
+ targets = [stmt.target]
380
+ for target in targets:
381
+ if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name):
382
+ cls_surface = classes.get(qualified(target.value.id))
383
+ if cls_surface is not None:
384
+ cls_surface.attributes.add(target.attr)
385
+
386
+ # Module-level functions (used for arity checks on imported callables).
387
+ for child in ast.iter_child_nodes(tree):
388
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
389
+ functions[qualified(child.name)] = _arity(child, bound_params=0)
390
+
391
+ return classes, functions
392
+
393
+
394
+ def file_fingerprint(path: Path) -> tuple[int, int]:
395
+ """Cheap cache key for a parsed file (mtime + size)."""
396
+ try:
397
+ st = os.stat(path)
398
+ except OSError:
399
+ return (0, 0)
400
+ return (int(st.st_mtime_ns), int(st.st_size))
401
+
402
+
403
+ def imported_symbols_of(tree: ast.AST) -> dict[str, str]:
404
+ """Map every imported local name in *tree* to its source.
405
+
406
+ Mirrors :class:`~honestcode.structure.extractor.StructureExtractor`'s
407
+ convention (``import a.b`` binds ``b`` -> ``a.b``; ``from a import name``
408
+ binds ``name`` -> ``a.name``). Local imports inside functions count too.
409
+ Star imports get a key prefixed with ``*`` whose value is the source
410
+ module, so several star imports in one file do not overwrite each other
411
+ and the validator can expand them against the symbol index.
412
+ """
413
+ out: dict[str, str] = {}
414
+ for node in ast.walk(tree):
415
+ if isinstance(node, ast.Import):
416
+ for alias in node.names:
417
+ asname = alias.asname or alias.name.split(".")[-1]
418
+ out[asname] = alias.name
419
+ elif isinstance(node, ast.ImportFrom):
420
+ module = node.module or ""
421
+ for alias in node.names:
422
+ if alias.name == "*":
423
+ out[f"*{module}"] = module
424
+ continue
425
+ asname = alias.asname or alias.name
426
+ out[asname] = f"{module}.{alias.name}" if module else alias.name
427
+ return out
428
+
429
+
430
+ # Parsed file facts shared across verifications: definition files are read
431
+ # once per (path, module, file_label, mtime, size) even though a single verify
432
+ # walks dozens of classes, and long-lived MCP sessions reuse everything across
433
+ # calls. Ordering matters: this used to happen once per class-base lookup,
434
+ # which parsed the same multi-thousand-line file 40 times in one verification.
435
+ _MAX_CACHED_FILES = 512
436
+ _FILE_FACTS: OrderedDict[
437
+ tuple[str, str, str, int, int],
438
+ tuple[dict[str, ClassSurface], dict[str, MethodInfo], dict[str, str]],
439
+ ] = OrderedDict()
440
+ # FastMCP runs sync tools on worker threads, so the LRU's
441
+ # get/move_to_end/insert/popitem sequence needs a lock to stay coherent.
442
+ _FILE_FACTS_LOCK = threading.Lock()
443
+
444
+
445
+ def _read_source(path_str: str) -> str | None:
446
+ """Read source fresh; ``None`` when unreadable.
447
+
448
+ This used to be ``lru_cache``-d hidden behind ``load_file_facts``'s
449
+ fingerprint key, so a file edit produced a *new* facts entry built from the
450
+ *old* cached bytes: a long-lived MCP session kept reporting a method the
451
+ agent had just added to a base class. The facts cache above already avoids
452
+ repeated reads while the fingerprint is unchanged, so the extra layer only
453
+ bought staleness.
454
+ """
455
+ return read_python_source(path_str)
456
+
457
+
458
+ def load_file_facts(
459
+ path: Path,
460
+ module: str,
461
+ file_label: str,
462
+ ) -> tuple[dict[str, ClassSurface], dict[str, MethodInfo], dict[str, str]]:
463
+ """Parse *path* once and return ``(classes, functions, imported_symbols)``.
464
+
465
+ Results are cached by ``(path, module, file_label, mtime_ns, size)`` across
466
+ calls; unreadable or unparsable files cache an empty result so repeated
467
+ scans do not retry. The cache key must include *module*: class qnames are
468
+ module-qualified, so the same file indexed under two different roots (a
469
+ repo that vendors another project, scanned once as a whole and once as its
470
+ own package) produced facts under whichever module was seen first, and the
471
+ other scan silently missed findings.
472
+ """
473
+ key = (str(path), module, file_label, *file_fingerprint(path))
474
+ with _FILE_FACTS_LOCK:
475
+ cached = _FILE_FACTS.get(key)
476
+ if cached is not None:
477
+ _FILE_FACTS.move_to_end(key)
478
+ return cached
479
+
480
+ source = _read_source(str(path))
481
+ facts: tuple[dict[str, ClassSurface], dict[str, MethodInfo], dict[str, str]]
482
+ if source is None:
483
+ facts = ({}, {}, {})
484
+ else:
485
+ try:
486
+ tree = ast.parse(source)
487
+ except (SyntaxError, ValueError):
488
+ facts = ({}, {}, {})
489
+ else:
490
+ classes, functions = extract_file_surfaces(tree, module=module, file_label=file_label)
491
+ facts = (classes, functions, imported_symbols_of(tree))
492
+
493
+ with _FILE_FACTS_LOCK:
494
+ _FILE_FACTS[key] = facts
495
+ if len(_FILE_FACTS) > _MAX_CACHED_FILES:
496
+ _FILE_FACTS.popitem(last=False)
497
+ return facts