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.
- honestcode/__init__.py +19 -0
- honestcode/cli.py +428 -0
- honestcode/graph/__init__.py +13 -0
- honestcode/graph/graph_store.py +276 -0
- honestcode/graph/watcher.py +116 -0
- honestcode/guards/__init__.py +104 -0
- honestcode/guards/guard.py +777 -0
- honestcode/guards/store.py +457 -0
- honestcode/guards/taxonomy.py +228 -0
- honestcode/honest/__init__.py +19 -0
- honestcode/honest/project_binding.py +200 -0
- honestcode/honest/router.py +205 -0
- honestcode/honest/symbol_index.py +446 -0
- honestcode/integrations/__init__.py +14 -0
- honestcode/integrations/pydantic_ai.py +304 -0
- honestcode/mcp/__init__.py +46 -0
- honestcode/mcp/knowledge_base.py +290 -0
- honestcode/mcp/server.py +357 -0
- honestcode/mcp/tools.py +2002 -0
- honestcode/py.typed +0 -0
- honestcode/sandbox/__init__.py +334 -0
- honestcode/structure/__init__.py +6 -0
- honestcode/structure/extractor.py +353 -0
- honestcode/structure/multi_lang.py +267 -0
- honestcode/structure/relations.py +47 -0
- honestcode/structure/utils.py +262 -0
- honestcode/verify/__init__.py +19 -0
- honestcode/verify/evidence.py +195 -0
- honestcode/verify/surface.py +497 -0
- honestcode/verify/verify.py +1074 -0
- honestcode-0.5.0.dist-info/METADATA +669 -0
- honestcode-0.5.0.dist-info/RECORD +36 -0
- honestcode-0.5.0.dist-info/WHEEL +5 -0
- honestcode-0.5.0.dist-info/entry_points.txt +3 -0
- honestcode-0.5.0.dist-info/licenses/LICENSE +21 -0
- honestcode-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -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
|