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,1074 @@
|
|
|
1
|
+
"""Ground-truth verification of a single Python file.
|
|
2
|
+
|
|
3
|
+
This is the loop HonestCode actually sells:
|
|
4
|
+
|
|
5
|
+
coding agent writes code → verify against the repository → evidence → fix
|
|
6
|
+
|
|
7
|
+
The verifier walks the AST of the file the agent just produced and, for every
|
|
8
|
+
call site, tries to *prove* it is grounded in the repository. When it cannot
|
|
9
|
+
prove grounding it emits a :class:`~honestcode.verify.evidence.Finding` that
|
|
10
|
+
carries the fix (`did_you_mean`, `available_methods`, `expected` / `actual`).
|
|
11
|
+
|
|
12
|
+
Design rule: **silence beats noise**. A call is reported only when the
|
|
13
|
+
verifier resolved the receiver to a concrete class whose member surface it
|
|
14
|
+
could fully reconstruct *and* proved the member absent. Unresolved bases,
|
|
15
|
+
``__getattr__`` hooks, ``*args``/``**kwargs`` unpacking, dynamically
|
|
16
|
+
decorated callables, and arities that keyword arguments could satisfy all
|
|
17
|
+
cause the check to be skipped rather than guessed — a false positive costs an
|
|
18
|
+
agent a wrong edit, which is worse than a missed one.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from difflib import get_close_matches
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
from honestcode.structure.extractor import StructureExtractor
|
|
30
|
+
from honestcode.structure.utils import (
|
|
31
|
+
call_name,
|
|
32
|
+
importable_root,
|
|
33
|
+
module_name_for,
|
|
34
|
+
stdlib_resolves,
|
|
35
|
+
stdlib_roots,
|
|
36
|
+
)
|
|
37
|
+
from honestcode.verify.evidence import (
|
|
38
|
+
ACTION_INSPECT,
|
|
39
|
+
CONFIDENCE_DETERMINISTIC,
|
|
40
|
+
CONFIDENCE_HIGH,
|
|
41
|
+
KIND_INVENTED_API,
|
|
42
|
+
KIND_SYNTAX_ERROR,
|
|
43
|
+
KIND_UNDEFINED_SYMBOL,
|
|
44
|
+
KIND_WRONG_CALL,
|
|
45
|
+
Finding,
|
|
46
|
+
)
|
|
47
|
+
from honestcode.verify.surface import (
|
|
48
|
+
EMPTY_BASES,
|
|
49
|
+
ClassSurface,
|
|
50
|
+
MethodInfo,
|
|
51
|
+
annotation_name,
|
|
52
|
+
extract_file_surfaces,
|
|
53
|
+
load_file_facts,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from collections.abc import Iterator
|
|
58
|
+
|
|
59
|
+
from honestcode.honest.symbol_index import ProjectIndex
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"SurfaceStore",
|
|
63
|
+
"resolve_package_relative_imports",
|
|
64
|
+
"star_imports_unresolved",
|
|
65
|
+
"valid_imported_names",
|
|
66
|
+
"verify_file_source",
|
|
67
|
+
"verify_tree",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
MAX_EVIDENCE_METHODS = 8
|
|
71
|
+
_NEAR_MISS_CUTOFF = 0.6
|
|
72
|
+
_MAX_BASE_DEPTH = 5
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _star_names(
|
|
76
|
+
module: str, index: ProjectIndex | None, seen: frozenset[str], depth: int = 0
|
|
77
|
+
) -> set[str] | None:
|
|
78
|
+
"""Public names ``from <module> import *`` binds, or ``None`` when the
|
|
79
|
+
source's export surface cannot be fully enumerated.
|
|
80
|
+
|
|
81
|
+
``None`` (not the empty set) is the load-bearing distinction: it means
|
|
82
|
+
absence cannot be proven, so callers must stay silent instead of
|
|
83
|
+
reporting. A module-level ``__getattr__`` (PEP 562) or a star of its own
|
|
84
|
+
makes the surface open; re-exported stars are expanded transitively with a
|
|
85
|
+
depth/cycle guard.
|
|
86
|
+
"""
|
|
87
|
+
if index is None or not module or depth > 4 or module in seen:
|
|
88
|
+
return None
|
|
89
|
+
names = index.module_symbols.get(module)
|
|
90
|
+
if names is None or "__getattr__" in names:
|
|
91
|
+
return None
|
|
92
|
+
out: set[str] = set()
|
|
93
|
+
seen = seen | {module}
|
|
94
|
+
for name in names:
|
|
95
|
+
if name.startswith("*"):
|
|
96
|
+
sub = _star_names(name[1:], index, seen, depth + 1)
|
|
97
|
+
if sub is None:
|
|
98
|
+
return None
|
|
99
|
+
out |= sub
|
|
100
|
+
elif not name.startswith("_"):
|
|
101
|
+
out.add(name)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def star_imports_unresolved(imported_symbols: dict[str, str], index: ProjectIndex | None) -> bool:
|
|
106
|
+
"""True when any star import's source cannot be enumerated.
|
|
107
|
+
|
|
108
|
+
An unenumerable ``from X import *`` may have bound any name, so bare calls
|
|
109
|
+
that nothing else grounds must not be reported as undefined in such files.
|
|
110
|
+
"""
|
|
111
|
+
for key, module in imported_symbols.items():
|
|
112
|
+
if not key.startswith("*"):
|
|
113
|
+
continue
|
|
114
|
+
if _star_names(module, index, frozenset()) is None:
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _index_knows(index: ProjectIndex, qname: str, project_modules: set[str]) -> bool:
|
|
120
|
+
"""Whether *qname* names (or lives inside) something the index has seen."""
|
|
121
|
+
if qname in index.symbols or qname in project_modules:
|
|
122
|
+
return True
|
|
123
|
+
mod, _, attr = qname.rpartition(".")
|
|
124
|
+
return bool(mod and attr and attr in index.module_symbols.get(mod, ()))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_package_relative_imports(
|
|
128
|
+
imported_symbols: dict[str, str],
|
|
129
|
+
index: ProjectIndex | None,
|
|
130
|
+
module: str,
|
|
131
|
+
) -> dict[str, str]:
|
|
132
|
+
"""Re-point unresolvable absolute imports at the file's own package.
|
|
133
|
+
|
|
134
|
+
A file inside a monorepo sub-project writes ``from pkg.foo import Thing``
|
|
135
|
+
meaning *its own* ``pkg`` — but the auto-discovered index is rooted at the
|
|
136
|
+
repository, where that module is ``services.web.pkg.foo.Thing``. The import
|
|
137
|
+
then fails every project-grounded check and a perfectly valid import was
|
|
138
|
+
reported as an undefined symbol. When the file's package directory joined
|
|
139
|
+
in front of the source *does* exist in the index, the import map is
|
|
140
|
+
rewritten to that absolute name; anything else is left untouched, so a
|
|
141
|
+
genuine hallucination is never silenced by this fallback.
|
|
142
|
+
"""
|
|
143
|
+
if index is None or not module or "." not in module:
|
|
144
|
+
return imported_symbols
|
|
145
|
+
package_dir = module.rpartition(".")[0]
|
|
146
|
+
|
|
147
|
+
project_roots: set[str] = set()
|
|
148
|
+
project_modules: set[str] = set()
|
|
149
|
+
for qname in index.symbols:
|
|
150
|
+
parts = qname.split(".")
|
|
151
|
+
project_roots.add(parts[0])
|
|
152
|
+
for i in range(1, len(parts)):
|
|
153
|
+
project_modules.add(".".join(parts[:i]))
|
|
154
|
+
|
|
155
|
+
out = dict(imported_symbols)
|
|
156
|
+
for local_name, source_name in imported_symbols.items():
|
|
157
|
+
if not source_name or local_name.startswith("*") or source_name.startswith("."):
|
|
158
|
+
continue
|
|
159
|
+
if _index_knows(index, source_name, project_modules):
|
|
160
|
+
continue
|
|
161
|
+
candidate = f"{package_dir}.{source_name}"
|
|
162
|
+
if _index_knows(index, candidate, project_modules):
|
|
163
|
+
out[local_name] = candidate
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def valid_imported_names(
|
|
168
|
+
imported_symbols: dict[str, str],
|
|
169
|
+
index: ProjectIndex | None,
|
|
170
|
+
dep_names: set[str],
|
|
171
|
+
dep_roots: set[str] | None = None,
|
|
172
|
+
) -> set[str]:
|
|
173
|
+
"""Return imported names that resolve to a known project or dependency symbol.
|
|
174
|
+
|
|
175
|
+
Without this filter, ``from myproject import does_not_exist`` is treated as
|
|
176
|
+
a defined name and the agent's hallucinated call is silently accepted. We
|
|
177
|
+
keep a name only when:
|
|
178
|
+
|
|
179
|
+
* the source symbol exists in the project index,
|
|
180
|
+
* the source is a known project module (``import lib.api``),
|
|
181
|
+
* the name is re-exported by a project module (``from .compat import
|
|
182
|
+
urlparse`` where ``compat`` itself imports it),
|
|
183
|
+
* the source root is a known dependency root (``import numpy as np``), or
|
|
184
|
+
* the full source is a known dependency API (``from math import sqrt``).
|
|
185
|
+
|
|
186
|
+
Standard-library sources get one extra guarantee: they are real, importable
|
|
187
|
+
code, so the attribute is verified against the live module — ``from typing
|
|
188
|
+
import TypeVarr`` is rejected while ``from typing import TypeVar`` passes.
|
|
189
|
+
Third-party roots are trusted wholesale when they were merely declared
|
|
190
|
+
(they may not be installed, so the name cannot be checked) — *dep_roots*
|
|
191
|
+
supplies those root names; when omitted they are derived from *dep_names*.
|
|
192
|
+
An undeclared root that is nevertheless importable in this environment
|
|
193
|
+
(``from requests import get`` with no manifest entry) is real code too:
|
|
194
|
+
the import is an undeclared dependency, not a hallucination, so its local
|
|
195
|
+
name is kept. Star imports are expanded against the index (see
|
|
196
|
+
:func:`_star_names`); callers can use :func:`star_imports_unresolved` to
|
|
197
|
+
stay silent when an expansion is impossible.
|
|
198
|
+
"""
|
|
199
|
+
valid: set[str] = set()
|
|
200
|
+
roots = dep_roots if dep_roots is not None else {n.split(".", 1)[0] for n in dep_names}
|
|
201
|
+
stdlib = stdlib_roots()
|
|
202
|
+
if index is None:
|
|
203
|
+
# No project index: fall back to the old trusting behaviour.
|
|
204
|
+
for local_name, source_name in imported_symbols.items():
|
|
205
|
+
valid.add(local_name)
|
|
206
|
+
if source_name:
|
|
207
|
+
valid.add(source_name.split(".")[0])
|
|
208
|
+
return valid
|
|
209
|
+
|
|
210
|
+
project_roots: set[str] = set()
|
|
211
|
+
project_modules: set[str] = set()
|
|
212
|
+
for qname in index.symbols:
|
|
213
|
+
parts = qname.split(".")
|
|
214
|
+
project_roots.add(parts[0])
|
|
215
|
+
for i in range(1, len(parts)):
|
|
216
|
+
project_modules.add(".".join(parts[:i]))
|
|
217
|
+
|
|
218
|
+
for local_name, source_name in imported_symbols.items():
|
|
219
|
+
if local_name.startswith("*"):
|
|
220
|
+
expanded = _star_names(imported_symbols[local_name], index, frozenset())
|
|
221
|
+
if expanded is not None:
|
|
222
|
+
valid |= expanded
|
|
223
|
+
continue
|
|
224
|
+
if not source_name:
|
|
225
|
+
valid.add(local_name)
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
root = source_name.split(".")[0]
|
|
229
|
+
|
|
230
|
+
# Project-grounded first, so a project that shadows a stdlib or
|
|
231
|
+
# dependency name still resolves through the index.
|
|
232
|
+
if source_name in index.symbols or source_name in project_modules:
|
|
233
|
+
valid.add(local_name)
|
|
234
|
+
continue
|
|
235
|
+
mod, _, attr = source_name.rpartition(".")
|
|
236
|
+
if attr and mod and attr in index.module_symbols.get(mod, ()):
|
|
237
|
+
# Re-export through a project module: ``from .compat import
|
|
238
|
+
# urlparse`` where compat.py itself imports it. The name is not a
|
|
239
|
+
# symbol of that module (it is an import there) but it is bound at
|
|
240
|
+
# its top level, so the importing file is grounded.
|
|
241
|
+
valid.add(local_name)
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
if root in roots:
|
|
245
|
+
valid.add(root)
|
|
246
|
+
if root in stdlib and "." in source_name and stdlib_resolves(source_name) is False:
|
|
247
|
+
# The stdlib module imports fine but lacks this attribute —
|
|
248
|
+
# an invented standard-library symbol.
|
|
249
|
+
continue
|
|
250
|
+
valid.add(local_name)
|
|
251
|
+
continue
|
|
252
|
+
if source_name in dep_names:
|
|
253
|
+
valid.add(local_name)
|
|
254
|
+
continue
|
|
255
|
+
if root in project_roots:
|
|
256
|
+
# ``from lib.api import does_not_exist`` keeps ``lib`` bound but
|
|
257
|
+
# rejects the invented local name.
|
|
258
|
+
valid.add(root)
|
|
259
|
+
continue
|
|
260
|
+
if importable_root(root):
|
|
261
|
+
# Installed in this environment, merely undeclared. The import is
|
|
262
|
+
# real code — an undeclared dependency, not a hallucination.
|
|
263
|
+
# Project roots were handled above, so a broken import from a
|
|
264
|
+
# shadowed project module cannot slip through here.
|
|
265
|
+
valid.add(root)
|
|
266
|
+
valid.add(local_name)
|
|
267
|
+
|
|
268
|
+
return valid
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ── Name resolution ─────────────────────────────────────────────────────
|
|
272
|
+
class NameResolver:
|
|
273
|
+
"""Resolve an identifier used in one file to a module-qualified name.
|
|
274
|
+
|
|
275
|
+
Resolution is purely syntactic (import map → same module → unique global
|
|
276
|
+
suffix match), so it never imports user code and never guesses.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def __init__(self, module: str, imported: dict[str, str], known: set[str]):
|
|
280
|
+
self.module = module
|
|
281
|
+
self.imported = imported
|
|
282
|
+
self._known = known
|
|
283
|
+
self._by_short: dict[str, list[str]] | None = None
|
|
284
|
+
|
|
285
|
+
def resolve(self, name: str) -> str | None:
|
|
286
|
+
src = self.imported.get(name)
|
|
287
|
+
if src:
|
|
288
|
+
for candidate in (src, src.lstrip(".")):
|
|
289
|
+
if candidate in self._known:
|
|
290
|
+
return candidate
|
|
291
|
+
if self.module:
|
|
292
|
+
joined = f"{self.module}.{src.lstrip('.')}"
|
|
293
|
+
if joined in self._known:
|
|
294
|
+
return joined
|
|
295
|
+
if self.module and f"{self.module}.{name}" in self._known:
|
|
296
|
+
return f"{self.module}.{name}"
|
|
297
|
+
if name in self._known:
|
|
298
|
+
return name
|
|
299
|
+
hits = self._suffix_hits(name)
|
|
300
|
+
return hits[0] if len(hits) == 1 else None
|
|
301
|
+
|
|
302
|
+
def resolve_dotted(self, name: str) -> str | None:
|
|
303
|
+
"""Resolve ``client.UserClient`` by resolving its head component."""
|
|
304
|
+
if not name:
|
|
305
|
+
return None
|
|
306
|
+
head, _, rest = name.partition(".")
|
|
307
|
+
target = self.resolve(head)
|
|
308
|
+
if target is None:
|
|
309
|
+
return None
|
|
310
|
+
return f"{target}.{rest}" if rest else target
|
|
311
|
+
|
|
312
|
+
def _suffix_hits(self, name: str) -> list[str]:
|
|
313
|
+
"""Globally unique short-name fallback (covers relative imports)."""
|
|
314
|
+
if self._by_short is None:
|
|
315
|
+
by_short: dict[str, list[str]] = {}
|
|
316
|
+
for key in self._known:
|
|
317
|
+
by_short.setdefault(key.rsplit(".", 1)[-1], []).append(key)
|
|
318
|
+
self._by_short = by_short
|
|
319
|
+
return self._by_short.get(name.rsplit(".", 1)[-1], [])
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ── Surface store ───────────────────────────────────────────────────────
|
|
323
|
+
@dataclass
|
|
324
|
+
class SurfaceView:
|
|
325
|
+
"""A class's member surface, flattened across resolved base classes."""
|
|
326
|
+
|
|
327
|
+
members: set[str]
|
|
328
|
+
methods: dict[str, MethodInfo]
|
|
329
|
+
complete: bool
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class SurfaceStore:
|
|
333
|
+
"""Lazily parses the files that define the classes under verification.
|
|
334
|
+
|
|
335
|
+
The file being verified is parsed fresh on every call (an agent may have
|
|
336
|
+
just written it), while every other file is parsed once and cached by
|
|
337
|
+
(mtime, size).
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
def __init__(
|
|
341
|
+
self,
|
|
342
|
+
root: Path | None,
|
|
343
|
+
index: ProjectIndex | None,
|
|
344
|
+
local_classes: dict[str, ClassSurface] | None = None,
|
|
345
|
+
local_functions: dict[str, MethodInfo] | None = None,
|
|
346
|
+
):
|
|
347
|
+
self.root = root
|
|
348
|
+
self.index = index
|
|
349
|
+
self.local_classes = local_classes or {}
|
|
350
|
+
self.local_functions = local_functions or {}
|
|
351
|
+
self._imported: dict[Path, dict[str, str]] = {}
|
|
352
|
+
self._resolvers: dict[Path, NameResolver] = {}
|
|
353
|
+
self._base_short_names: set[str] | None = None
|
|
354
|
+
|
|
355
|
+
def _facts_for(
|
|
356
|
+
self, path: Path
|
|
357
|
+
) -> tuple[dict[str, ClassSurface], dict[str, MethodInfo], dict[str, str]]:
|
|
358
|
+
"""Return cached ``(classes, functions, imports)`` for *path*.
|
|
359
|
+
|
|
360
|
+
The shared ``load_file_facts`` cache is keyed by (path, mtime, size),
|
|
361
|
+
so a file is parsed once per edit no matter how many classes, bases,
|
|
362
|
+
or verifications reference it.
|
|
363
|
+
"""
|
|
364
|
+
module = module_name_for(path, self.root) if self.root else path.stem
|
|
365
|
+
return load_file_facts(path, module, str(path))
|
|
366
|
+
|
|
367
|
+
def _surfaces_for(self, path: Path) -> tuple[dict[str, ClassSurface], dict[str, MethodInfo]]:
|
|
368
|
+
classes, functions, imported = self._facts_for(path)
|
|
369
|
+
self._imported[path] = imported
|
|
370
|
+
return classes, functions
|
|
371
|
+
|
|
372
|
+
def imported_for(self, path: Path) -> dict[str, str]:
|
|
373
|
+
"""Imported-symbol map of *path*, from the shared parse cache."""
|
|
374
|
+
cached = self._imported.get(path)
|
|
375
|
+
if cached is not None:
|
|
376
|
+
return cached
|
|
377
|
+
_classes, _functions, imported = self._facts_for(path)
|
|
378
|
+
self._imported[path] = imported
|
|
379
|
+
return imported
|
|
380
|
+
|
|
381
|
+
def resolver_for(self, path: Path, imported: dict[str, str]) -> NameResolver:
|
|
382
|
+
cached = self._resolvers.get(path)
|
|
383
|
+
if cached is not None and cached.imported == imported:
|
|
384
|
+
return cached
|
|
385
|
+
module = module_name_for(path, self.root) if self.root else path.stem
|
|
386
|
+
known = set(self.index.symbols) if self.index else set()
|
|
387
|
+
classes, functions = self._surfaces_for(path)
|
|
388
|
+
known |= set(classes) | set(functions)
|
|
389
|
+
resolver = NameResolver(module, imported, known)
|
|
390
|
+
self._resolvers[path] = resolver
|
|
391
|
+
return resolver
|
|
392
|
+
|
|
393
|
+
def class_surface(self, qname: str) -> ClassSurface | None:
|
|
394
|
+
if qname in self.local_classes:
|
|
395
|
+
return self.local_classes[qname]
|
|
396
|
+
if self.index is None or self.root is None:
|
|
397
|
+
return None
|
|
398
|
+
info = self.index.symbols.get(qname)
|
|
399
|
+
if info is None or info.kind != "class":
|
|
400
|
+
return None
|
|
401
|
+
path = self.root / info.file
|
|
402
|
+
classes, _functions = self._surfaces_for(path)
|
|
403
|
+
return classes.get(qname)
|
|
404
|
+
|
|
405
|
+
def function(self, qname: str) -> MethodInfo | None:
|
|
406
|
+
if qname in self.local_functions:
|
|
407
|
+
return self.local_functions[qname]
|
|
408
|
+
if self.index is None or self.root is None:
|
|
409
|
+
return None
|
|
410
|
+
info = self.index.symbols.get(qname)
|
|
411
|
+
if info is None or info.kind != "function":
|
|
412
|
+
return None
|
|
413
|
+
_classes, functions = self._surfaces_for(self.root / info.file)
|
|
414
|
+
return functions.get(qname)
|
|
415
|
+
|
|
416
|
+
def view(self, qname: str, _depth: int = 0) -> SurfaceView | None:
|
|
417
|
+
"""Flatten a class's member surface.
|
|
418
|
+
|
|
419
|
+
Returns ``None`` when the surface is unknowable — the class is missing,
|
|
420
|
+
defines ``__getattr__``, or a base could not be resolved deeply enough.
|
|
421
|
+
"""
|
|
422
|
+
if _depth > _MAX_BASE_DEPTH:
|
|
423
|
+
return None
|
|
424
|
+
surface = self.class_surface(qname)
|
|
425
|
+
if surface is None or surface.dynamic:
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
members = set(surface.members)
|
|
429
|
+
methods = dict(surface.methods)
|
|
430
|
+
# A surface is complete only when nothing outside it can contribute
|
|
431
|
+
# members: every syntactic base was resolvable, no metaclass (which may
|
|
432
|
+
# inject arbitrary members) and no class decorator (``@attr.s``-style
|
|
433
|
+
# decorators add methods) was applied. ``decorated`` used to be
|
|
434
|
+
# recorded and never read, so ``@add_methods class Foo`` reported
|
|
435
|
+
# ``Foo.injected()`` as an invented API.
|
|
436
|
+
complete = (
|
|
437
|
+
surface.unknown_bases == 0 and not surface.has_metaclass and not surface.decorated
|
|
438
|
+
)
|
|
439
|
+
for base in surface.bases:
|
|
440
|
+
if not base or base in EMPTY_BASES:
|
|
441
|
+
continue
|
|
442
|
+
resolver = self._resolver_for_definition(surface)
|
|
443
|
+
base_qname = resolver.resolve_dotted(base) if resolver else None
|
|
444
|
+
if base_qname is None or base_qname == qname:
|
|
445
|
+
complete = False
|
|
446
|
+
continue
|
|
447
|
+
parent = self.view(base_qname, _depth + 1)
|
|
448
|
+
if parent is None:
|
|
449
|
+
complete = False
|
|
450
|
+
continue
|
|
451
|
+
members |= parent.members
|
|
452
|
+
methods.update({k: v for k, v in parent.methods.items() if k not in methods})
|
|
453
|
+
complete = complete and parent.complete
|
|
454
|
+
return SurfaceView(members=members, methods=methods, complete=complete)
|
|
455
|
+
|
|
456
|
+
def _resolver_for_definition(self, surface: ClassSurface) -> NameResolver | None:
|
|
457
|
+
path = Path(surface.file)
|
|
458
|
+
if not path.is_absolute() and self.root is not None:
|
|
459
|
+
path = self.root / surface.file
|
|
460
|
+
if not path.exists():
|
|
461
|
+
return None
|
|
462
|
+
# Imported symbols come from the shared parse cache — this used to
|
|
463
|
+
# re-parse the whole file on every base-class lookup.
|
|
464
|
+
return self.resolver_for(path, self.imported_for(path))
|
|
465
|
+
|
|
466
|
+
def may_be_subclassed(self, qname: str) -> bool:
|
|
467
|
+
"""Whether any known class lists this class's short name as a base.
|
|
468
|
+
|
|
469
|
+
A mixin component (PyYAML's ``Scanner``, composed as ``class
|
|
470
|
+
Loader(Reader, Scanner, Parser, ...)``) calls members it does not
|
|
471
|
+
define; the member that grounds such a call lives in a sibling
|
|
472
|
+
component. The check is name-based on purpose: resolving every base in
|
|
473
|
+
the project would cost more than the answer is worth, and a short-name
|
|
474
|
+
collision only ever makes the verifier quieter, never louder.
|
|
475
|
+
|
|
476
|
+
The base-name table comes from the symbol index (recorded at index
|
|
477
|
+
time), so this costs one set lookup; the old implementation parsed
|
|
478
|
+
every class-defining file on first use, which made a cold scan of a
|
|
479
|
+
few-hundred-file project take seconds. Indexes without the table (an
|
|
480
|
+
older build) fall back to that scan.
|
|
481
|
+
"""
|
|
482
|
+
if self._base_short_names is None:
|
|
483
|
+
names: set[str] = set()
|
|
484
|
+
for surface in self.local_classes.values():
|
|
485
|
+
names.update(b.rsplit(".", 1)[-1] for b in surface.bases)
|
|
486
|
+
if self.index is not None:
|
|
487
|
+
indexed = getattr(self.index, "class_base_names", None)
|
|
488
|
+
if indexed is not None:
|
|
489
|
+
names.update(indexed)
|
|
490
|
+
else:
|
|
491
|
+
for qname_, info in self.index.symbols.items():
|
|
492
|
+
if info.kind != "class":
|
|
493
|
+
continue
|
|
494
|
+
surface = self.class_surface(qname_)
|
|
495
|
+
if surface is not None:
|
|
496
|
+
names.update(b.rsplit(".", 1)[-1] for b in surface.bases)
|
|
497
|
+
self._base_short_names = names
|
|
498
|
+
return qname.rsplit(".", 1)[-1] in self._base_short_names
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# ── Scope tracking ──────────────────────────────────────────────────────
|
|
502
|
+
_BINDING_CARRY: tuple[type[ast.AST], ...] = tuple(
|
|
503
|
+
t
|
|
504
|
+
for t in (
|
|
505
|
+
ast.If,
|
|
506
|
+
ast.For,
|
|
507
|
+
ast.AsyncFor,
|
|
508
|
+
ast.While,
|
|
509
|
+
ast.With,
|
|
510
|
+
ast.AsyncWith,
|
|
511
|
+
ast.Try,
|
|
512
|
+
getattr(ast, "TryStar", None),
|
|
513
|
+
)
|
|
514
|
+
if t is not None
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _iter_binding_stmts(node: ast.AST) -> Iterator[ast.stmt]:
|
|
519
|
+
"""Yield assignment statements that bind names in *node*'s scope.
|
|
520
|
+
|
|
521
|
+
Control-flow blocks are descended into (an assignment under ``if`` or
|
|
522
|
+
``try`` still binds the name in this scope), but nested function and class
|
|
523
|
+
bodies are not — their assignments live in their own scope. This used to
|
|
524
|
+
look only at the direct children of *node*, which made bindings hidden
|
|
525
|
+
behind a branch invisible to the verifier.
|
|
526
|
+
"""
|
|
527
|
+
for child in ast.iter_child_nodes(node):
|
|
528
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
529
|
+
continue
|
|
530
|
+
if isinstance(child, (ast.Assign, ast.AnnAssign)):
|
|
531
|
+
yield child
|
|
532
|
+
elif isinstance(child, _BINDING_CARRY):
|
|
533
|
+
yield from _iter_binding_stmts(child)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _bindings_of(node: ast.AST, *, is_function: bool) -> dict[str, str]:
|
|
537
|
+
"""Collect ``variable -> type name`` bindings introduced in *node*'s scope.
|
|
538
|
+
|
|
539
|
+
The walk is not flow-sensitive: it sees the whole scope at once, so a
|
|
540
|
+
variable assigned from two different constructors is indistinguishable
|
|
541
|
+
from one whose type changes mid-body. Applying the *last* assignment to
|
|
542
|
+
every use (the previous behaviour) flagged valid calls as invented APIs —
|
|
543
|
+
``client = AClient(); client.push(); client = BClient()`` reported
|
|
544
|
+
``BClient.push()`` as missing on a line that runs against ``AClient``.
|
|
545
|
+
A name bound to more than one distinct type therefore collapses to the
|
|
546
|
+
empty string, which every consumer treats as "unknown — stay silent".
|
|
547
|
+
"""
|
|
548
|
+
types_by_name: dict[str, set[str]] = {}
|
|
549
|
+
if is_function:
|
|
550
|
+
for arg in _all_args(node): # type: ignore[arg-type]
|
|
551
|
+
if arg.annotation is not None:
|
|
552
|
+
name = annotation_name(arg.annotation)
|
|
553
|
+
if name:
|
|
554
|
+
types_by_name.setdefault(arg.arg, set()).add(name)
|
|
555
|
+
for stmt in _iter_binding_stmts(node):
|
|
556
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
557
|
+
name = annotation_name(stmt.annotation) if stmt.annotation else None
|
|
558
|
+
if name is None and isinstance(stmt.value, ast.Call):
|
|
559
|
+
name = call_name(stmt.value.func)
|
|
560
|
+
if name:
|
|
561
|
+
types_by_name.setdefault(stmt.target.id, set()).add(name)
|
|
562
|
+
elif isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Call):
|
|
563
|
+
name = call_name(stmt.value.func)
|
|
564
|
+
if name:
|
|
565
|
+
for target in stmt.targets:
|
|
566
|
+
if isinstance(target, ast.Name):
|
|
567
|
+
types_by_name.setdefault(target.id, set()).add(name)
|
|
568
|
+
return {
|
|
569
|
+
name: next(iter(types)) if len(types) == 1 else "" for name, types in types_by_name.items()
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _all_args(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.arg]:
|
|
574
|
+
a = node.args
|
|
575
|
+
return [*getattr(a, "posonlyargs", []), *a.args, *a.kwonlyargs]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class _Scope:
|
|
579
|
+
def __init__(self) -> None:
|
|
580
|
+
self.stack: list[dict[str, str]] = [{}]
|
|
581
|
+
self.class_qname: str | None = None
|
|
582
|
+
self.class_stack: list[str | None] = [None]
|
|
583
|
+
self.self_params: list[str | None] = [None]
|
|
584
|
+
|
|
585
|
+
def push(
|
|
586
|
+
self,
|
|
587
|
+
bindings: dict[str, str],
|
|
588
|
+
class_qname: str | None = None,
|
|
589
|
+
self_param: str | None = None,
|
|
590
|
+
) -> None:
|
|
591
|
+
self.stack.append(bindings)
|
|
592
|
+
self.class_stack.append(class_qname)
|
|
593
|
+
self.self_params.append(self_param)
|
|
594
|
+
self.class_qname = class_qname
|
|
595
|
+
|
|
596
|
+
def pop(self) -> None:
|
|
597
|
+
self.stack.pop()
|
|
598
|
+
self.class_stack.pop()
|
|
599
|
+
self.self_params.pop()
|
|
600
|
+
self.class_qname = self.class_stack[-1]
|
|
601
|
+
|
|
602
|
+
@property
|
|
603
|
+
def self_param(self) -> str | None:
|
|
604
|
+
"""First parameter name of the current function frame, if any."""
|
|
605
|
+
return self.self_params[-1]
|
|
606
|
+
|
|
607
|
+
def lookup(self, name: str) -> str | None:
|
|
608
|
+
for frame in reversed(self.stack):
|
|
609
|
+
if name in frame:
|
|
610
|
+
return frame[name]
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _first_param_name(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None:
|
|
615
|
+
"""Name of the first parameter (the ``self``/``cls`` slot), or ``None``.
|
|
616
|
+
|
|
617
|
+
``None`` for static methods (no bound first argument) and for functions
|
|
618
|
+
with no positional parameters. Inside such a frame, a name that happens to
|
|
619
|
+
read ``self`` or ``cls`` is an ordinary variable — ``cls`` in
|
|
620
|
+
``_ClassBuilder.__init__(self, cls, ...)`` is the class *being built*, and
|
|
621
|
+
resolving it to the enclosing class reported ``cls.__attrs_init_subclass__()``
|
|
622
|
+
as an invented API on attrs' own class builder.
|
|
623
|
+
"""
|
|
624
|
+
for decorator in node.decorator_list:
|
|
625
|
+
name = call_name(decorator)
|
|
626
|
+
if name and name.split(".")[-1] == "staticmethod":
|
|
627
|
+
return None
|
|
628
|
+
pos = [*getattr(node.args, "posonlyargs", []), *node.args.args]
|
|
629
|
+
return pos[0].arg if pos else None
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# ── The verifier ────────────────────────────────────────────────────────
|
|
633
|
+
@dataclass
|
|
634
|
+
class _Verifier:
|
|
635
|
+
path: Path
|
|
636
|
+
root: Path | None
|
|
637
|
+
index: ProjectIndex | None
|
|
638
|
+
defined: set[str]
|
|
639
|
+
dep_names: set[str]
|
|
640
|
+
resolver: NameResolver
|
|
641
|
+
store: SurfaceStore
|
|
642
|
+
module: str
|
|
643
|
+
dep_roots: set[str] | None = None
|
|
644
|
+
dep_enumerated: set[str] | None = None
|
|
645
|
+
# Set when the file has a star import whose source cannot be enumerated;
|
|
646
|
+
# unproven bare calls must then stay silent (see star_imports_unresolved).
|
|
647
|
+
star_open: bool = False
|
|
648
|
+
findings: list[Finding] = field(default_factory=list)
|
|
649
|
+
checked: int = 0
|
|
650
|
+
_dep_roots: set[str] = field(init=False)
|
|
651
|
+
_dep_enumerated: set[str] = field(init=False)
|
|
652
|
+
|
|
653
|
+
def __post_init__(self) -> None:
|
|
654
|
+
self._dep_roots = {n.split(".", 1)[0] for n in self.dep_names} | set(self.dep_roots or ())
|
|
655
|
+
# Member checks need a module whose surface was actually enumerated;
|
|
656
|
+
# a declared-but-uninstalled package knows nothing about its members,
|
|
657
|
+
# so ``tomllib.loads`` on a declared-but-absent ``tomli`` must stay
|
|
658
|
+
# silent rather than be reported as invented.
|
|
659
|
+
if self.dep_enumerated is not None:
|
|
660
|
+
self._dep_enumerated = set(self.dep_enumerated)
|
|
661
|
+
else:
|
|
662
|
+
self._dep_enumerated = {n for n in self.dep_names if "." not in n} | {
|
|
663
|
+
r for r in self._dep_roots if r in self.dep_names
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
# -- helpers ---------------------------------------------------------
|
|
667
|
+
def _finding(self, **kwargs: Any) -> None:
|
|
668
|
+
self.findings.append(Finding(file=str(self.path), **kwargs))
|
|
669
|
+
|
|
670
|
+
def _public(self, members: set[str]) -> list[str]:
|
|
671
|
+
return sorted(m for m in members if not m.startswith("_"))
|
|
672
|
+
|
|
673
|
+
def _display(self, qname: str) -> str:
|
|
674
|
+
return qname.rsplit(".", 1)[-1]
|
|
675
|
+
|
|
676
|
+
# -- entry point -----------------------------------------------------
|
|
677
|
+
def run(self, tree: ast.AST) -> None:
|
|
678
|
+
scope = _Scope()
|
|
679
|
+
scope.stack = [_bindings_of(tree, is_function=False)]
|
|
680
|
+
self._walk(tree, scope)
|
|
681
|
+
|
|
682
|
+
def _walk(self, node: ast.AST, scope: _Scope) -> None:
|
|
683
|
+
for child in ast.iter_child_nodes(node):
|
|
684
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
685
|
+
scope.push(
|
|
686
|
+
_bindings_of(child, is_function=True),
|
|
687
|
+
scope.class_qname,
|
|
688
|
+
_first_param_name(child),
|
|
689
|
+
)
|
|
690
|
+
self._walk(child, scope)
|
|
691
|
+
scope.pop()
|
|
692
|
+
elif isinstance(child, ast.ClassDef):
|
|
693
|
+
qname = f"{self.module}.{child.name}" if self.module else child.name
|
|
694
|
+
parent = scope.class_qname
|
|
695
|
+
qname = f"{parent}.{child.name}" if parent else qname
|
|
696
|
+
scope.push(_bindings_of(child, is_function=False), qname)
|
|
697
|
+
self._walk(child, scope)
|
|
698
|
+
scope.pop()
|
|
699
|
+
else:
|
|
700
|
+
if isinstance(child, ast.Call):
|
|
701
|
+
self._check_call(child, scope)
|
|
702
|
+
self._walk(child, scope)
|
|
703
|
+
|
|
704
|
+
# -- checks ----------------------------------------------------------
|
|
705
|
+
def _resolve_self_chain(self, receiver: str, class_qname: str) -> str | None:
|
|
706
|
+
"""Resolve ``self.client`` / ``self`` to the type of the final attribute."""
|
|
707
|
+
if receiver in ("self", "cls"):
|
|
708
|
+
return class_qname
|
|
709
|
+
parts = receiver.split(".")
|
|
710
|
+
if parts[0] not in ("self", "cls"):
|
|
711
|
+
return None
|
|
712
|
+
qname = class_qname
|
|
713
|
+
for attr in parts[1:]:
|
|
714
|
+
surface = self.store.class_surface(qname)
|
|
715
|
+
if surface is None:
|
|
716
|
+
return None
|
|
717
|
+
# Prefer instance-attribute type, then class attribute, then method return type.
|
|
718
|
+
next_type = surface.instance_attrs.get(attr)
|
|
719
|
+
if next_type is None and attr in surface.attributes:
|
|
720
|
+
return None # class attr without type info — cannot continue
|
|
721
|
+
if not next_type:
|
|
722
|
+
# Known instance attribute with an unknown type
|
|
723
|
+
# (``self.x = <expr>``) — the chain cannot continue.
|
|
724
|
+
return None
|
|
725
|
+
qname = self.resolver.resolve_dotted(next_type)
|
|
726
|
+
if qname is None:
|
|
727
|
+
return None
|
|
728
|
+
return qname
|
|
729
|
+
|
|
730
|
+
def _check_call(self, call: ast.Call, scope: _Scope) -> None:
|
|
731
|
+
name = call_name(call.func)
|
|
732
|
+
if name is None:
|
|
733
|
+
# A method called on a freshly constructed object — ``Cls().method()``
|
|
734
|
+
# — has no dotted name, but its receiver type is known whenever
|
|
735
|
+
# ``Cls`` resolves to a class in the index. Skipping it let invented
|
|
736
|
+
# methods on one of the most common Python call styles go
|
|
737
|
+
# unreported. Everything else here (``(a + b).foo()``,
|
|
738
|
+
# ``super().x()``) stays unjudged.
|
|
739
|
+
self._check_constructed_receiver_call(call)
|
|
740
|
+
return
|
|
741
|
+
self.checked += 1
|
|
742
|
+
receiver, _, attr = name.rpartition(".")
|
|
743
|
+
if not receiver:
|
|
744
|
+
self._check_bare_call(call, name)
|
|
745
|
+
return
|
|
746
|
+
first = scope.self_param
|
|
747
|
+
is_bound_receiver = first is not None and (
|
|
748
|
+
receiver == first or receiver.startswith(f"{first}.")
|
|
749
|
+
)
|
|
750
|
+
if is_bound_receiver and scope.class_qname is not None:
|
|
751
|
+
qname = self._resolve_self_chain(receiver, scope.class_qname)
|
|
752
|
+
if qname is None:
|
|
753
|
+
return
|
|
754
|
+
# Only a direct ``self``/``cls`` receiver needs the mixin gate;
|
|
755
|
+
# ``self.client.x()`` resolves to a concrete class like any
|
|
756
|
+
# annotated receiver.
|
|
757
|
+
self._check_member_call(call, qname, attr, via_self=receiver == first)
|
|
758
|
+
return
|
|
759
|
+
# A dependency module alias (`pd.read_excel`) is checked against the
|
|
760
|
+
# loaded dependency API index.
|
|
761
|
+
if self._check_dependency_call(call, receiver, attr):
|
|
762
|
+
return
|
|
763
|
+
type_name = scope.lookup(receiver)
|
|
764
|
+
if not type_name:
|
|
765
|
+
return
|
|
766
|
+
qname = self.resolver.resolve_dotted(type_name)
|
|
767
|
+
if qname is None:
|
|
768
|
+
return
|
|
769
|
+
self._check_member_call(call, qname, attr)
|
|
770
|
+
|
|
771
|
+
def _check_constructed_receiver_call(self, call: ast.Call) -> None:
|
|
772
|
+
"""Verify ``Cls().method()`` against the member surface of ``Cls``.
|
|
773
|
+
|
|
774
|
+
Handles only the constructor form. A receiver produced by a factory
|
|
775
|
+
function (``make_client().method()``) is not judged, because function
|
|
776
|
+
signatures carry no return annotation in the surface model — guessing
|
|
777
|
+
there would trade a missed finding for a false one.
|
|
778
|
+
"""
|
|
779
|
+
func = call.func
|
|
780
|
+
if not isinstance(func, ast.Attribute):
|
|
781
|
+
return
|
|
782
|
+
inner = func.value
|
|
783
|
+
if not isinstance(inner, ast.Call):
|
|
784
|
+
return
|
|
785
|
+
inner_name = call_name(inner.func)
|
|
786
|
+
if inner_name is None:
|
|
787
|
+
return
|
|
788
|
+
qname = self.resolver.resolve(inner_name)
|
|
789
|
+
if qname is None:
|
|
790
|
+
return
|
|
791
|
+
if self.store.class_surface(qname) is None:
|
|
792
|
+
return # Not a class — nothing to verify against.
|
|
793
|
+
self.checked += 1
|
|
794
|
+
self._check_member_call(call, qname, func.attr)
|
|
795
|
+
|
|
796
|
+
def _check_bare_call(self, call: ast.Call, name: str) -> None:
|
|
797
|
+
base = name.split(".")[0]
|
|
798
|
+
if base not in self.defined and name not in self.defined:
|
|
799
|
+
if self.star_open:
|
|
800
|
+
# A star import whose source could not be enumerated may have
|
|
801
|
+
# bound this name — absence cannot be proven, stay silent.
|
|
802
|
+
return
|
|
803
|
+
# "high", not "deterministic": name absence rests on the syntactic
|
|
804
|
+
# binding model of this file (imports, assignments, index), which
|
|
805
|
+
# by design has escape hatches (exec, dynamic patching, names the
|
|
806
|
+
# model never sees). Member-surface claims are the only ones the
|
|
807
|
+
# verifier can actually prove.
|
|
808
|
+
self._finding(
|
|
809
|
+
line=call.lineno,
|
|
810
|
+
kind=KIND_UNDEFINED_SYMBOL,
|
|
811
|
+
symbol=name,
|
|
812
|
+
message=f"`{name}` is not defined in this project or in any loaded dependency.",
|
|
813
|
+
evidence={"kind": "call"},
|
|
814
|
+
confidence=CONFIDENCE_HIGH,
|
|
815
|
+
action=ACTION_INSPECT,
|
|
816
|
+
)
|
|
817
|
+
return
|
|
818
|
+
# Grounded: check the call's arity when we can see the definition.
|
|
819
|
+
qname = self.resolver.resolve(name)
|
|
820
|
+
if qname is None:
|
|
821
|
+
return
|
|
822
|
+
info = self.store.function(qname)
|
|
823
|
+
if info is None:
|
|
824
|
+
surface = self.store.class_surface(qname)
|
|
825
|
+
info = surface.methods.get("__init__") if surface else None
|
|
826
|
+
if info is not None:
|
|
827
|
+
self._check_arity(call, f"{self._display(qname)}()", info)
|
|
828
|
+
|
|
829
|
+
def _check_dependency_call(self, call: ast.Call, receiver: str, attr: str) -> bool:
|
|
830
|
+
"""Verify ``alias.attr()`` against the dependency API index.
|
|
831
|
+
|
|
832
|
+
Only modules whose member surface was actually enumerated are checked:
|
|
833
|
+
for a declared-but-uninstalled package, or a submodule the loader never
|
|
834
|
+
walked into, "not in the index" is not evidence of absence.
|
|
835
|
+
"""
|
|
836
|
+
if not self._dep_roots:
|
|
837
|
+
return False
|
|
838
|
+
module = receiver if receiver in self._dep_roots else None
|
|
839
|
+
if module is None:
|
|
840
|
+
mapped = self.resolver.imported.get(receiver)
|
|
841
|
+
if mapped and mapped.split(".")[0] in self._dep_roots:
|
|
842
|
+
# Use the FULL mapped source, not its root: for
|
|
843
|
+
# ``from numpy import random; random.seed()`` the check must
|
|
844
|
+
# run against ``numpy.random.seed``, not ``numpy.seed``.
|
|
845
|
+
# Collapsing to the root used to flag perfectly valid calls
|
|
846
|
+
# on aliased submodules as invented APIs.
|
|
847
|
+
module = mapped
|
|
848
|
+
if module is None or module not in self._dep_enumerated:
|
|
849
|
+
return False
|
|
850
|
+
|
|
851
|
+
full = f"{module}.{attr}"
|
|
852
|
+
if full in self.dep_names:
|
|
853
|
+
return True
|
|
854
|
+
|
|
855
|
+
members = [k.rsplit(".", 1)[-1] for k in self.dep_names if k.startswith(f"{module}.")]
|
|
856
|
+
near = get_close_matches(attr, members, n=1, cutoff=_NEAR_MISS_CUTOFF)
|
|
857
|
+
evidence: dict[str, Any] = {
|
|
858
|
+
"available_methods": sorted(set(members))[:MAX_EVIDENCE_METHODS]
|
|
859
|
+
}
|
|
860
|
+
if near:
|
|
861
|
+
evidence["did_you_mean"] = near[0]
|
|
862
|
+
self._finding(
|
|
863
|
+
line=call.lineno,
|
|
864
|
+
kind=KIND_INVENTED_API,
|
|
865
|
+
symbol=attr,
|
|
866
|
+
owner=module,
|
|
867
|
+
message=f"`{module}.{attr}()` does not exist.",
|
|
868
|
+
evidence=evidence,
|
|
869
|
+
confidence=CONFIDENCE_DETERMINISTIC,
|
|
870
|
+
)
|
|
871
|
+
return True
|
|
872
|
+
|
|
873
|
+
def _check_member_call(
|
|
874
|
+
self, call: ast.Call, qname: str, attr: str, *, via_self: bool = False
|
|
875
|
+
) -> None:
|
|
876
|
+
view = self.store.view(qname)
|
|
877
|
+
if view is None:
|
|
878
|
+
return # Unknown surface — stay silent rather than guess.
|
|
879
|
+
owner = self._display(qname)
|
|
880
|
+
|
|
881
|
+
if attr in view.members:
|
|
882
|
+
info = view.methods.get(attr)
|
|
883
|
+
if info is not None:
|
|
884
|
+
self._check_arity(call, f"{owner}.{attr}()", info)
|
|
885
|
+
return
|
|
886
|
+
|
|
887
|
+
if not view.complete:
|
|
888
|
+
# An unresolved base (a stdlib ABC, a third-party parent) may
|
|
889
|
+
# legitimately provide this member, so absence cannot be proven.
|
|
890
|
+
# Reporting anyway used to produce false "high confidence"
|
|
891
|
+
# findings on real code — e.g. ``CaseInsensitiveDict.items()``,
|
|
892
|
+
# supplied by the unresolvable ``MutableMapping`` base, was
|
|
893
|
+
# reported with ``did_you_mean: lower_items``.
|
|
894
|
+
return
|
|
895
|
+
|
|
896
|
+
if via_self and self.store.may_be_subclassed(qname):
|
|
897
|
+
# The receiver is ``self``: at runtime it may be an instance of any
|
|
898
|
+
# subclass, and a class composed elsewhere in the project
|
|
899
|
+
# (``class Loader(Reader, Scanner, Parser, ...)``) supplies members
|
|
900
|
+
# this class does not name. Absence cannot be proven — stay silent.
|
|
901
|
+
return
|
|
902
|
+
|
|
903
|
+
public = self._public(view.members)
|
|
904
|
+
evidence: dict[str, Any] = {"available_methods": public[:MAX_EVIDENCE_METHODS]}
|
|
905
|
+
near = get_close_matches(attr, public, n=1, cutoff=_NEAR_MISS_CUTOFF)
|
|
906
|
+
if near:
|
|
907
|
+
evidence["did_you_mean"] = near[0]
|
|
908
|
+
self._finding(
|
|
909
|
+
line=call.lineno,
|
|
910
|
+
kind=KIND_INVENTED_API,
|
|
911
|
+
symbol=attr,
|
|
912
|
+
owner=owner,
|
|
913
|
+
message=f"`{owner}.{attr}()` does not exist.",
|
|
914
|
+
evidence=evidence,
|
|
915
|
+
confidence=CONFIDENCE_DETERMINISTIC,
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
def _check_arity(self, call: ast.Call, label: str, info: MethodInfo) -> None:
|
|
919
|
+
if info.is_property:
|
|
920
|
+
return # Properties are accessed, not called.
|
|
921
|
+
if not info.arity_trustworthy:
|
|
922
|
+
return # A decorator may have rewritten the signature.
|
|
923
|
+
if any(isinstance(a, ast.Starred) for a in call.args):
|
|
924
|
+
return # Caller unpacks a sequence — count is unknown.
|
|
925
|
+
|
|
926
|
+
positional = len(call.args)
|
|
927
|
+
keyword_names = [kw.arg for kw in call.keywords if kw.arg is not None]
|
|
928
|
+
keyword_set = set(keyword_names)
|
|
929
|
+
has_starstar = any(kw.arg is None for kw in call.keywords)
|
|
930
|
+
|
|
931
|
+
# Too many positional arguments is an error no matter how the call
|
|
932
|
+
# spells its keywords.
|
|
933
|
+
if info.max_args is not None and positional > info.max_args:
|
|
934
|
+
self._arity_finding(call, label, info, positional, keyword_names, "too many arguments")
|
|
935
|
+
return
|
|
936
|
+
|
|
937
|
+
# Too few: required parameters may also be satisfied by keyword —
|
|
938
|
+
# ``f(a, b)`` called as ``f(a=1, b=2)`` is valid, and keyword-call
|
|
939
|
+
# style is the norm in modern Python (``verify_tree(tree, path=...)``).
|
|
940
|
+
if not has_starstar:
|
|
941
|
+
unfilled = info.required_positional[positional:]
|
|
942
|
+
missing = [n for n in unfilled if n not in keyword_set]
|
|
943
|
+
missing += [n for n in sorted(info.required_keywords) if n not in keyword_set]
|
|
944
|
+
# **kwargs on the definition cannot fill *named* required
|
|
945
|
+
# parameters, so a missing name is still conclusive.
|
|
946
|
+
if missing:
|
|
947
|
+
self._arity_finding(
|
|
948
|
+
call, label, info, positional, keyword_names, "too few arguments"
|
|
949
|
+
)
|
|
950
|
+
return
|
|
951
|
+
|
|
952
|
+
if keyword_names and not info.has_kwargs and not has_starstar:
|
|
953
|
+
unknown = [k for k in keyword_names if k not in info.keywords]
|
|
954
|
+
if unknown:
|
|
955
|
+
self._finding(
|
|
956
|
+
line=call.lineno,
|
|
957
|
+
kind=KIND_WRONG_CALL,
|
|
958
|
+
symbol=label,
|
|
959
|
+
message=f"`{label}` does not accept keyword argument(s): "
|
|
960
|
+
f"{', '.join(sorted(unknown))}.",
|
|
961
|
+
evidence={
|
|
962
|
+
"expected": ", ".join(sorted(info.keywords)) or "none",
|
|
963
|
+
"actual": ", ".join(sorted(unknown)),
|
|
964
|
+
},
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
def _arity_finding(
|
|
968
|
+
self,
|
|
969
|
+
call: ast.Call,
|
|
970
|
+
label: str,
|
|
971
|
+
info: MethodInfo,
|
|
972
|
+
positional: int,
|
|
973
|
+
keyword_names: list[str],
|
|
974
|
+
problem: str,
|
|
975
|
+
) -> None:
|
|
976
|
+
if keyword_names:
|
|
977
|
+
actual = f"{positional} positional + {len(keyword_names)} keyword argument(s)"
|
|
978
|
+
else:
|
|
979
|
+
actual = f"{positional} argument(s)"
|
|
980
|
+
self._finding(
|
|
981
|
+
line=call.lineno,
|
|
982
|
+
kind=KIND_WRONG_CALL,
|
|
983
|
+
symbol=label,
|
|
984
|
+
message=f"`{label}` called with {problem}: expected {info.describe()}, got {actual}.",
|
|
985
|
+
evidence={"expected": info.describe(), "actual": actual},
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def verify_tree(
|
|
990
|
+
tree: ast.AST,
|
|
991
|
+
*,
|
|
992
|
+
path: Path,
|
|
993
|
+
root: Path | None,
|
|
994
|
+
index: ProjectIndex | None,
|
|
995
|
+
defined: set[str],
|
|
996
|
+
dep_names: set[str],
|
|
997
|
+
imported_symbols: dict[str, str],
|
|
998
|
+
dep_roots: set[str] | None = None,
|
|
999
|
+
dep_enumerated: set[str] | None = None,
|
|
1000
|
+
star_open: bool = False,
|
|
1001
|
+
) -> tuple[list[Finding], int]:
|
|
1002
|
+
"""Verify every call site in *tree* and return ``(findings, checked)``.
|
|
1003
|
+
|
|
1004
|
+
*dep_roots* optionally supplies trusted dependency root names beyond those
|
|
1005
|
+
derivable from *dep_names* (see :func:`valid_imported_names`);
|
|
1006
|
+
*dep_enumerated* lists the dependency modules whose member surface is
|
|
1007
|
+
known, which is what makes invented-API reporting sound; *star_open* is
|
|
1008
|
+
true when the file star-imports a module whose export surface cannot be
|
|
1009
|
+
enumerated (see :func:`star_imports_unresolved`).
|
|
1010
|
+
"""
|
|
1011
|
+
module = module_name_for(path, root) if root else path.stem
|
|
1012
|
+
local_classes, local_functions = extract_file_surfaces(
|
|
1013
|
+
tree, module=module, file_label=str(path)
|
|
1014
|
+
)
|
|
1015
|
+
known = set(index.symbols) if index else set()
|
|
1016
|
+
known |= set(local_classes) | set(local_functions)
|
|
1017
|
+
resolver = NameResolver(module, imported_symbols, known)
|
|
1018
|
+
store = SurfaceStore(root, index, local_classes, local_functions)
|
|
1019
|
+
|
|
1020
|
+
verifier = _Verifier(
|
|
1021
|
+
path=path,
|
|
1022
|
+
root=root,
|
|
1023
|
+
index=index,
|
|
1024
|
+
defined=defined,
|
|
1025
|
+
dep_names=dep_names,
|
|
1026
|
+
dep_roots=dep_roots,
|
|
1027
|
+
dep_enumerated=dep_enumerated,
|
|
1028
|
+
star_open=star_open,
|
|
1029
|
+
resolver=resolver,
|
|
1030
|
+
store=store,
|
|
1031
|
+
module=module,
|
|
1032
|
+
)
|
|
1033
|
+
verifier.run(tree)
|
|
1034
|
+
return verifier.findings, verifier.checked
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def verify_file_source(
|
|
1038
|
+
source: str,
|
|
1039
|
+
*,
|
|
1040
|
+
path: Path,
|
|
1041
|
+
root: Path | None,
|
|
1042
|
+
index: ProjectIndex | None,
|
|
1043
|
+
defined: set[str],
|
|
1044
|
+
dep_names: set[str],
|
|
1045
|
+
) -> tuple[list[Finding], int]:
|
|
1046
|
+
"""Parse *source* and verify every call site in it."""
|
|
1047
|
+
try:
|
|
1048
|
+
tree = ast.parse(source, filename=str(path))
|
|
1049
|
+
except SyntaxError as exc:
|
|
1050
|
+
finding = Finding(
|
|
1051
|
+
file=str(path),
|
|
1052
|
+
line=exc.lineno or 1,
|
|
1053
|
+
kind=KIND_SYNTAX_ERROR,
|
|
1054
|
+
symbol="<syntax>",
|
|
1055
|
+
message=f"SyntaxError: {exc.msg}",
|
|
1056
|
+
evidence={"offset": exc.offset or 0},
|
|
1057
|
+
action=ACTION_INSPECT,
|
|
1058
|
+
)
|
|
1059
|
+
return [finding], 0
|
|
1060
|
+
try:
|
|
1061
|
+
imported = StructureExtractor().parse_source(source).imported_symbols or {}
|
|
1062
|
+
except Exception: # noqa: BLE001 - defensive
|
|
1063
|
+
imported = {}
|
|
1064
|
+
module = module_name_for(path, root) if root else path.stem
|
|
1065
|
+
imported = resolve_package_relative_imports(imported, index, module)
|
|
1066
|
+
return verify_tree(
|
|
1067
|
+
tree,
|
|
1068
|
+
path=path,
|
|
1069
|
+
root=root,
|
|
1070
|
+
index=index,
|
|
1071
|
+
defined=defined,
|
|
1072
|
+
dep_names=dep_names,
|
|
1073
|
+
imported_symbols=imported,
|
|
1074
|
+
)
|