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,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
+ )