38 Commits
Author SHA1 Message Date
Martin Vogel 054719044a fix(python): bind ClassName() only to the class own __init__
A construction call now resolves to __init__ only when the called
class declares that __init__ itself. An inherited __init__ is no
longer followed: class B(A) without its own __init__ keeps the B()
call on the Class B, as it was before this branch.

Following the bases bound every subclass construction to the base
constructor. On a django index (fb113765) that made Model.__init__
a 583-caller hub (every model instantiation), bound IntegerField()
to Field.__init__ and HttpResponseForbidden() to
HttpResponse.__init__, and degraded 625 constructor call sites
versus main (457 fell back to textual resolution, 168 lost their
row; CALLS -161). With own __init__ only, 6016 sites move to the
class own __init__, 5468 stay on the Class, none fall back to a
textual strategy, and total CALLS is +4 versus main.

Tests: the same-file and cross-file inherited cases now pin the
Class target, and a new same-file subclass test at the LSP and the
graph level covers B(A) without __init__ (stays on B, never
A.__init__) and C(A) with its own __init__ (binds to C.__init__,
never the class C or A.__init__). The strategy inventory comment in
the repro suite is updated to match.

Refs #1642

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-11-01 21:29:22 +01:00
Matt Van Horn 1b64c50c54 fix(python): resolve ClassName() calls to __init__
A Python construction call resolved to the Class node, so a user
__init__ never received a caller from ClassName(...) and the dead-code
view reported live constructors as unreferenced.

When the called name is the class itself, resolve it to the __init__
found on the class or, depth-first, its declared bases (the lookup
super().__init__() already uses), with a new lsp_constructor_init
strategy that joins the textual call site through its reason. Classes
with no user __init__ keep the Class target (lsp_constructor), as does
a builtins.* __init__. Calls through an instance (f = Foo(); f()) are
unchanged, since they dispatch to __call__.

This changes the documented Class-target contract for classes that
define __init__; the three tests that pinned it are updated.

Refs #1642

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-11-01 09:11:36 -08:00
Martin Vogel 5985131d86 Merge origin/main into perf/py-cross-registry-index
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-28 23:51:03 +01:00
Martin Vogel f08dadae9c test(py-lsp): gate the shared cross-registry build on counted work
Replace the wall-clock ratio test pylsp_scale_cross_registry_build_linear
(py_lsp_scale suite) with pylsp_cross_registry_build_work_is_linear in
the py_lsp suite. The new test builds the same generated input (n
classes, each followed by two methods, in extraction order) and reads
the seam-build CBM_LSP_WORK counter instead of a clock, so its verdict
does not depend on host load, and it runs in PR CI, which skips the
perf suites.

4x the classes must cost at most ~4x the counted work. With the
type-QN set the build examines no registry candidates (0 at 1000 and
4000 classes); with the scanning receiver probe the work grows from
2,809,000 to 23,236,000 and the test fails.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-20 19:29:02 +02:00
Martin Vogel 895b451fb8 Merge origin/main into perf/py-cross-registry-index
Brings #2420 (O(1) type membership in the shared Python cross-registry
build, #2413) up to date with main a2227157.

Conflicts in internal/cbm/lsp/py_lsp.c, resolved as follows:

- Includes: main already includes src/foundation/hash_table.h (module
  re-exports) and added lsp_node_iter.h. Main's lines are kept; the
  branch's duplicate include is dropped.

- py_register_lsp_defs: main changed it to return bool (false when the
  package scope cannot be normalized). The merged signature keeps main's
  bool return and adds this branch's PyKnownTypes *known parameter.

- cbm_run_py_lsp_cross_status: main's failure handling is kept and
  passes known = NULL, as the branch did for the per-file path.

- cbm_py_build_cross_registry: main added an early `return NULL` when a
  def fails to register. The merged loop passes &known and frees the
  type-QN set before that early return, so the set does not leak on the
  failure path.

- The test seam cbm_py_lsp_test_register_defs (added on main) passes
  known = NULL.

tests/test_py_lsp.c and tests/test_py_lsp_scale.c merged cleanly.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-20 19:29:02 +02:00
Martin Vogel 64349dedab Merge branch 'main' into fix/py-definitions-oom
Brings the branch up to main a2227157. One conflict, in
py_emit_call_for: main now skips lambda replay while a class body
executes (class_body_outer_scope). The resolution keeps that guard
together with this branch's registry index:

    int lambda_idx = ctx->class_body_outer_scope ? -1 : py_lookup_lambda(ctx, fname);

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-20 19:27:55 +02:00
Martin Vogel d4beec3c58 test(python): local classes are extracted defs now (#1548)
pylsp_nested_and_local_classbody_calls_join_extracted_caller asserted that a class declared in a function body is NOT a def, which is the behaviour #1548 fixes. Their class-body calls still join the enclosing function; that part is unchanged.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-18 04:48:27 +02:00
Martin Vogel 198d5b3651 test(python): read module-range bindings through CBMVarBinding
cbm_scope_lookup_local returns the binding, not its type, so the range
tests passed a const CBMVarBinding * where a const CBMType * was
expected and the test runner did not compile
(-Werror,-Wincompatible-pointer-types). Type checks now go through
pylsp_local_type, Hidden keeps the binding so the package case still
asserts that nothing is bound, and shadow_only checks the type of its
binding.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-13 00:33:09 +02:00
Martin Vogel 24eb264530 perf(python): restrict module binding scans to qualified-name ranges
Preserve registry array order and full-scan fallback when optional query
storage is unavailable. Keep temporary range storage scoped to each module
operation and shared type indexes in the registry owner arena.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-13 00:18:18 +02:00
Martin Vogel 23e291058c perf(python): index unresolved submodule prefix probes
Reuse a stable arena-owned function QN order for ambiguity marking and
submodule prefix lookup, preserving exact lookup policy and appended tails.
Add prefix/index failure regressions and a pipeline operation-count case.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-12 22:45:32 +02:00
Martin Vogel 78aea85b68 fix(python): resolve class-body and decorator calls
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-12 22:05:21 +02:00
Martin Vogel 7effa3cc59 test(python): check the type of the package-scope Store binding
cbm_scope_lookup_local() returns the frame binding (CBMVarBinding), not
its type, so passing it to pylsp_init_named_type() did not compile
(-Werror=incompatible-pointer-types). Add pylsp_local_type(), which
returns the binding's type field or NULL when the name is unbound, and
assert on that type as the test intended.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-12 21:58:25 +02:00
Martin Vogel 132ded8c60 fix(python): separate package symbol scope from file identity
Resolve symbols declared in __init__.py under their package while retaining
the raw module identity for file and top-level caller nodes. Carry explicit
language through registry callers, select Python package imports, preserve
long exact names, and report scope allocation failures without partial output.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-12 21:57:54 +02:00
Martin Vogel bf5ea5a460 fix(python): join module-level calls to exact LSP resolutions
Preserve the arena-owned raw Module caller name so top-level resolutions
join the extractor in both pipeline drivers. Add occurrence and ownership
controls, including package modules and named lambdas.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-12 21:48:41 +02:00
Martin Vogel e9e72d093c fix(lsp): evaluate operator and receiver chains iteratively
The depth guard of the C/C++ and Python expression evaluators
(C_EVAL_DEPTH_LIMIT, PY_LSP_MAX_EVAL_DEPTH = 256) now reports a sticky
per-file failure that stops the walk and drops every LSP resolution of the
file. Both evaluators recursed once per chain link, so a 300-term sum or a
C++ fluent chain of ~130 calls counted as 256 levels of nesting: one long
expression cost its whole file every LSP call edge. A chain's length is not
nesting, and the guard must not decide graph content for it.

c_eval_expr_type and py_eval_expr_type now first walk down the chain
operands below the node (the operand the evaluator evaluates first and
derives the link's type from), then evaluate them innermost first at the
caller's depth, keeping pending links on a small arena-owned stack in the
context that nested evaluations share. When a link is evaluated, its chain
operand is already memoized, so the evaluator's own recursive call into it
is a memo hit one frame deep. Every node is still evaluated by the
unchanged inner evaluator, evaluation does not mutate scope within one
outermost call, and only operands the recursion itself evaluates are
pre-evaluated, so the types are exactly the recursive ones.

No longer counted as depth (any length):
- C/C++: binary operators other than == != < > <= >= && || (those yield
  bool without evaluating an operand), member access (. and ->), calls on
  a non-name callee, subscripts, conditional consequences, parentheses,
  comma lists.
- Python: binary operators, attribute access, calls on obj.m(...), f()()
  and (expr)(), subscripts, the else branch of a conditional, parentheses.

Still counted, unchanged guard and reporting (genuine nesting):
- C/C++: call arguments used for template argument deduction,
  std::move/std::forward operands, unary, pointer and update operands,
  assignment left sides, lambda return expressions, co_await, fold and
  _Generic operands.
- Python: tuple/list/set elements, dictionary keys and values, the true
  branch of a conditional, await operands, next()/iter()/assert_type()
  arguments, called lambdas.

Proof:
- New c_lsp and py_lsp tests build chains of 300, 2,000 and 20,000 terms
  (a binary + chain and a .a() receiver chain) next to an unrelated s.m().
  On the previous evaluator, and with this change reverted, all four fail
  at 300 terms: the depth error is recorded and the file resolves 0 calls
  (C++ per-file; Python per-file and through the cross-file dispatch).
  With it, every size records no error, resolves run -> S.m, and the
  chain's result type resolves x.done() / the final .done().
- Real input, isolated cache, before and after:
  redis 4f20cb48 (C): 36,876 CALLS, 10,699 LSP-resolved;
  django fb113765 (Python): 60,123 CALLS, 25,153 LSP-resolved.
  Identical CALLS edge sets on both (0 lost, 0 gained, 0 strategy
  changes), 0 files with an LSP error before and after, wall time
  unchanged (redis 5.5 s, django 9.1-10.2 s both ways).

Refs #1527

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-12 21:29:13 +02:00
Martin Vogel c3c97a99d7 fix(lsp): bound repeated resolver work without truncating large files
Memoize C expression evaluation, index repeated scope and registry queries,
and report incomplete walks explicitly on memo or depth failure.

Refs #1527

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-12 21:29:00 +02:00
Nelson Spence 3b79e3d3bd Merge branch 'main' into perf/py-cross-registry-index
Signed-off-by: Nelson Spence <nelson@projectnavi.ai>
2026-10-04 17:08:38 -05:00
Martin Vogel 519c765e1f fix(python): retain typed fields across file boundaries
Carry declared instance-field types through extraction and result
compaction into the shared Python type registry so chained calls through
imported classes resolve their receiver types.

Refs #1277

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-03 01:37:27 +02:00
Nelson Spence ffa0b32004 fix(py-lsp): walk each lambda body at most once per call chain
py_emit_call_for re-walked a called lambda's body without tracking lambdas
already being walked, so a lambda that reaches itself was re-walked once per
nesting level up to the walk-depth cap, doubling with two calls per body.
mpmath's calculus/extrapolation.py grows memory at about 1.9 GB/s until it is
killed. Mark the registry entry as expanding during its re-walk and skip
re-entry.

The mpmath file now indexes in 1.1 s at about 80 MB peak RSS; the graph of
the Python 3.14 stdlib + site-packages is byte-identical.

Signed-off-by: Nelson Spence <nelson@projectnavi.ai>
2026-09-28 14:06:44 -05:00
Nelson Spence e36930742a perf(py-lsp): O(1) type membership in the shared Python cross-registry build
The shared tier-2 registry registered Python defs one at a time without a
mid-build finalize, so every Method's receiver probe scanned all registered
types linearly: O(methods x types), single-threaded in lsp_cross_prepare.
Keep the per-def registration order and back only the existence probe with a
cbm_ht set of type QNs, as the Go and Rust builders do; the registry is
identical by construction.

On the Python 3.14 stdlib + site-packages the builder drops from 67-75 s to
0.20-0.25 s, with a byte-identical graph.

Signed-off-by: Nelson Spence <nelson@projectnavi.ai>
2026-09-28 14:06:44 -05:00
Martin Vogel 300225c09d wip: capture remaining semantic reference regressions
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-18 15:52:50 +02:00
Martin Vogel 87a0e3f74f fix: preserve semantic graph relationships
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-18 15:52:50 +02:00
Martin Vogel d3225485c2 fix(py-lsp): resolve calls through aliased imports
from m import f as g; g() produced no CALLS edge at all. Two stacked
defects: (1) py_lsp_bind_imports keyed from-style detection on the
local name matching the module-path tail - an alias never matches, so
g was bound MODULE(m.f) and the call missed; (2) even with the binding
fixed, the pass join compares the bare tail of the resolved QN (f)
against the textual callee (g) and dropped the resolution.

Fix: (1) a dotted module path whose tail differs from the local name
is an aliased binding - bind NAMED(qn) and let phase 6 upgrade it to
the registered function/class (from-import alias) or module (import
a.b as z); (2) when the identifier-callee NAMED branch resolves a name
whose textual form differs from the QN tail, emit strategy
lsp_import_alias with the textual name stashed in reason, and gate
that strategy in the join's reason list - the exact pattern already
used by lsp_dict_dispatch and lsp_func_ptr. The parallel path's hash
index misses the alias key and falls through to the same gated linear
scan.

The contract test pins all four import forms (aliased from-import,
plain from-import, single-segment module alias, dotted module alias)
so the flip from MODULE to NAMED binding cannot regress the module-
alias attribute chains.

Closes #988

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-12 14:51:02 +02:00
Martin Vogel bd005b4b09 fix(py-lsp): route instance-field discovery through a per-file overlay
py_register_instance_field mutated the shared Tier-2 registry through direct
struct writes that bypass the read_only seal: it overwrote field-type pointers
in place and reassigned the sealed type entry's field arrays to memory owned by
the per-file arena. In the fused parallel resolve path (one sealed registry
shared by all resolve workers) that is a cross-thread data race, and it leaves
the shared registry pointing into arenas that die with their file -- a
cross-file use-after-free. The count-based seal guard could not see it because
type/func counts never change.

Python extraction emits no instance fields, so resolve-time discovery is the
ONLY source of self.x / PEP-526 fields; a plain read_only gate would silently
drop real attribute-chain CALLS edges. Fix instead routes discovery into a
per-file field overlay on PyLSPContext (arena-backed, dies with the file) and
has py_lookup_field_depth consult shared-base + overlay at the exact precedence
point of the direct field scan. Mutable per-file registries keep the original
in-place write, byte-identical.

Reproduce-first, both halves pinned:
- seal_py_shared_registry_readonly_fields: RED on the bug (field count 0->1 on
  a sealed registry), GREEN with the overlay.
- pylsp_fused_self_attr_chain_via_overlay: GREEN with the overlay, RED under a
  simulated plain gate (self.b.work() CALLS edge lost) -- proven both ways.
Suites: c_lsp 749, py_lsp 63+45 stress/scale/bench, pipeline 216, registry 54,
scope 10, type_rep 19 -- zero failures.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-06 15:58:18 +02:00
673b2201fb fix(py-lsp): memoize + guard expression evaluation (exponential re-eval hang)
py_eval_expr_type evaluated a call node's attribute receiver twice (once
in the container special-case, once in the general attribute path), so an
N-link chained call cost O(2^N) evaluations — real-world ~65-link builder
chains hung indexing for hours (#710). The evaluator also recursed once
per expression nesting level with no depth guard, so a pathologically deep
expression overflowed the native stack (#720).

Three guard layers, mirroring c_eval_expr_type's design in c_lsp.c:

1. Memoization: py_eval_expr_type is now a wrapper around the renamed
   py_eval_expr_type_uncached. Results are cached per node in an
   open-addressing table on PyLSPContext (arena-allocated, cold per file),
   keyed by node identity (TSNode.id) — NOT the start byte, since every
   leftmost descendant of a chain shares it and byte-keyed entries alias
   distinct nodes into silently wrong resolutions. Entries carry a scope
   generation; every scope bind/restore bumps it (O(1) whole-cache flush),
   so re-evaluation under changed bindings (lambda re-walks, isinstance
   narrowing) behaves exactly as before. Inserts are load-bounded (<75%)
   and rejected when growth fails, so a full table can never spin.

2. Depth cap: PY_LSP_MAX_EVAL_DEPTH 256 (same as C_EVAL_DEPTH_LIMIT);
   past the cap the evaluator returns unknown instead of overflowing the
   stack. Truncated results — including any ancestor of a truncation —
   are never cached, so a depth- or budget-limited `unknown` cannot
   poison post-truncation reuse.

3. Step budget: PY_EVAL_MAX_STEPS_PER_FILE 10000 (mirrors
   C_EVAL_MAX_STEPS_PER_FILE); pathological files degrade to unknown
   instead of stalling indexing. Cache hits don't consume budget.

Distills PR #732 (memoization; fixed the start-byte cache key and the
missing staleness handling) and PR #758 (depth cap; raised 64 -> 256 to
match the sibling LSPs, added the never-cache-truncated ordering), and
adds the step budget plus reproduce-first tests: a 40-link chain that
must resolve its final link, a heterogeneous-receiver chain that fails
under byte keying, a 30k-deep expression crash guard, and a budget
degradation guard.

Fixes #710. Fixes #720.

Co-authored-by: LA-10 <lashuha@uwaterloo.com>
Co-authored-by: Dustin Persek <dustin.persek@protonmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-03 19:49:03 +02:00
Martin Vogel 7e4745ddd1 test(py-lsp): regression for #228 (cross-file classmethod on class)
#228: a class/static method invoked on a cross-file imported class name
(ActionRecordX.build_from_text(...)) produced no CALLS edge, so the
method looked like dead code (in/out degree 0). The v0.7.0 py_lsp
cross-file resolver handles this; the test feeds the exact pattern through
cbm_run_py_lsp_cross and asserts the call resolves.

Closes #228.
2026-05-31 03:14:17 +02:00
Martin Vogel fd81dffc8f feat(py_lsp): decorator flags + verifications for generators / dataclasses / properties
Round 6:

CBMRegisteredFunc gains a `flags` bitfield (CBMFuncFlags) and a
`decorator_qns` NULL-terminated list. py_register_def populates them
from CBMDefinition.decorators by short-name match — so `@property`,
`@functools.cache`, `@abc.abstractmethod`, `@typing.overload`, and
`@final` all light up regardless of qualified vs bare reference. The
flags aren't consumed by resolution yet (still on roadmap: emit
distinct edge for @property attribute access vs method call); they
are recorded so future overload selection / property modeling can
use them without another schema change.

The struct extension is at-tail and existing call sites that memset
to zero stay correct; non-Python LSPs see flags=0 / decorator_qns=NULL
and behave identically.

Three new verification tests prove that generators, dataclasses, and
@property already work end-to-end through the Round 4-5 infrastructure
without further code changes:
- Generator[Foo, ...] iterables in for-loop bind loop var to Foo via
  py_iterable_element_type's existing TEMPLATE handling.
- @dataclass class Point: x: Foo (class-body annotated assignment) is
  picked up by Round 4's third-pass field registrar; p.x.method()
  resolves through the field type.
- @property def thing(self) -> Foo: c.thing returns Foo because
  attribute access on NAMED routes through py_func_return_type which
  yields the getter's annotated return.

All 2895 prior tests stay green; 3 new tests pass. Total 2898 / 0.
Bench unchanged at 95% (the small bench fixture doesn't exercise
the new patterns; will expand in Round 7).
2026-05-09 10:02:12 +02:00
Martin Vogel ce54e817b7 feat(py_lsp): subscript + super().__init__() + operator dunders
Round 5 parity push:

Subscript value typing — py_eval_expr_type's subscript branch resolves
indexing against the container's TEMPLATE type:
- TEMPLATE("dict"|"Mapping"|"defaultdict"|"OrderedDict"|...)[K] -> V
- TEMPLATE("list"|"set"|"frozenset"|"Sequence"|"Iterable"|...)[i] -> elem
- TEMPLATE("tuple", [A, B, ...])[literal_int] -> args[idx]
  - Non-literal index returns UNION of all element types.

This unlocks the `self.cache[k].method()` pattern that's everywhere in
real Python. Also fixes `items[0].method()` for typed lists.

super().__init__(...) constructor edge — super().<attr> already worked
for regular methods; __init__ is special-cased because parent classes
typically don't register __init__ with a useful return type. Strategy
"lsp_super_init" with confidence 0.85.

Operator dunders — a + b looks up __add__ on a's type and uses its
return type. Same for *, -, /, //, %, **, <<, >>, &, |, ^, @. Falls
back to left-operand type when registry has no info (correct for the
common int+int / str+str / list+list cases).

3 new tests; all 2892 prior tests stay green. Bench unchanged at 95%
(fixture doesn't exercise these patterns directly — Round 6 will
expand it).
2026-05-09 09:53:43 +02:00
Martin Vogel 4b993899a0 feat(py_lsp): instance attribute typing — 79% -> 95% resolution
Round 4 / final parity push. Tracks self.x = expr from __init__ and
class-body annotated assignments (`x: Foo`) as registered fields on
the enclosing class type.

py_register_instance_field mutates the registry's CBMRegisteredType
field_names / field_types arrays in place. Re-registering the same
name overwrites; first-seen wins for capture order. Arena-allocated,
not a hot path.

py_lookup_field walks the alias / embedded chain with the same
CBM_LSP_MAX_LOOKUP_DEPTH cap as method lookup, so inherited fields
resolve through MRO without stack-overflow risk on pathological
hierarchies.

Field-fallback wired into py_eval_expr_type's attribute branch:
when method lookup misses, try field lookup. Returns the field's
type so downstream attribute / call resolution chains naturally.
Same fallback applied to UNION (single-match heuristic).

py_process_class now does three passes:
1. Class-body annotated assignments (`x: int = ...`) → fields.
2. __init__ / __post_init__ → self.x = expr fields.
3. All other methods.
This guarantees fields are registered before methods that reference
them, regardless of source order.

Annotated assignment binding fix: py_resolve_annotation is now called
on assignment annotations too, so `self.cache: dict[str, str] = {}`
binds with the proper TEMPLATE type instead of NAMED("dict[str, str]").

Bench result on the 136-line fixture: **95%** (36/38 calls resolved,
8.06 ms). Up from 79%. The 5% gap covers cases that need a full
constraint solver — see Final Stopping Point doc.

2 new tests; all 2890 prior tests stay green. Total 2892 / 0.
2026-05-09 03:38:06 +02:00
Martin Vogel e7dcadc3dd feat(py_lsp): match/case narrowing, await passthrough, expanded bench
Round 3 parity push:

- match/case (PEP 634): when the case pattern is class_pattern (wrapped
  in a case_pattern), narrow the match subject identifier to NAMED(class)
  inside the case body. Push/pop scope per case. Capture sub-patterns
  bind to UNKNOWN (field-type extraction is a v1.1 task — would require
  field type info in the registry).
- await expr: identity passthrough. async def f() -> T registers with
  return T, so `await f()` returning T is the resolved-call shape we
  want without modeling Coroutine[Any, Any, T] explicitly.
- Expanded test_py_lsp_bench.c fixture from 65 lines to 136 lines
  exercising every parity feature: dotted-module walks, dataclass +
  Self chaining, multi-inheritance with super(), Optional/Union
  annotations + narrowing (isinstance / is not None / walrus), generic
  list[T] / dict[K, V] / defaultdict comprehensions, classmethod /
  staticmethod, match/case dispatch, async/await, common stdlib
  (logging.getLogger, json.loads, pathlib.Path, functools.lru_cache).

Bench result on the new fixture: 38 calls, 30 resolved, 79%, 7.50 ms.
Remaining 21% includes patterns where intermediate types lose precision
(e.g. super() constructor, dict subscript), instance attribute typing
(self.x where x isn't annotated at class scope), and decorator
return-type substitution (lru_cache).

All 2888 prior tests stay green; 2 new tests + bench expansion. Total
2890 / 0.
2026-05-09 03:29:51 +02:00
Martin Vogel fa45e7defc feat(py_lsp): narrowing, walrus, comprehension element typing
Round 2/3 parity push. Three connected gap fixes that share an annotation-
parser and a generic-aware iterable model.

Annotation parser upgrade:
- py_resolve_annotation now turns subscripted forms into structured types
  rather than stripping. `Optional[Foo]` -> UNION(NAMED(Foo), None).
  `Union[A, B]` -> UNION. `list[Foo]` / `dict[K, V]` / `Iterable[T]` ->
  TEMPLATE with arg list. `X | Y` (PEP 604) handled at depth zero.
- New py_parse_type_text_qn variant: same parser without scope lookup,
  used by py_register_def at registry-build time. Bare class names are
  qualified to <module_qn>.<name> so registry lookups match.
- py_register_def prefers d->return_type (full subscripted text) over
  d->return_types[0] when brackets are present — extract_defs's array
  form strips the subscript, losing element types like Optional[Foo].

Narrowing:
- if isinstance(x, T): bind x as NAMED(T) inside the consequence body
  (block-scoped via push/pop).
- if x is not None: strip None from x's UNION inside the consequence;
  py_match_is_none recognizes "is not" as the single tree-sitter token
  it actually is, walks through parens / walrus to find the underlying
  identifier.
- Else branch unchanged — narrow's negation (not-T, including-None) is
  not modeled in v1.

Walrus + truthy:
- if (x := compute()) is not None: walrus binds x in the enclosing scope
  (PEP 572) so the body sees x. py_bind_walrus_in walks the cond AST
  looking for named_expression / assignment_expression nodes.

Comprehension + for-loop element typing:
- New py_iterable_element_type maps TEMPLATE(list/set/Iterable/...) to
  arg[0]; TEMPLATE(dict) to key arg; TEMPLATE(tuple, ...) to UNION of
  args.
- Comprehensions push their own scope and bind for_in_clause loop vars
  to the iterable's element type before walking body / filter clauses.
  for_statement does the same.

UNION attribute access fallback (used when narrowing can't run):
- obj_type=UNION & exactly one non-None NAMED member has the attr:
  emit edge with strategy=lsp_method_union, confidence 0.85.

7 new test_py_lsp.c cases. All 2880 prior tests stay green; 8 new tests
land. Bench unchanged (small fixture, no narrowing exercised there).
2026-05-09 03:17:37 +02:00
Martin Vogel 2e042dab0b feat(py_lsp): Round 1 parity push (parent-modules, cast, Self, fwd-refs)
Parity push #1 — six gap fixes on top of the 11-phase plan:

- Parent-module bindings: `import a.b.c` now binds `a`, `a.b`, `a.b.c`
  via py_bind_dotted_prefixes. Walks the dotted prefix chain on every
  import, regardless of import_is_from_style. Plus an attribute-access
  path on a MODULE that detects submodules: when the registry has any
  function whose qn starts with `<mod>.<attr>.`, evaluating `mod.attr`
  yields MODULE("<mod>.<attr>"). This makes `os.path.join(...)` and
  similar resolve correctly.

- typing.cast(T, x) returns NAMED(T) (re-evaluating T as an annotation,
  so generic subscripts and forward references both resolve). Detects
  bare `cast(...)` and qualified `typing.cast(...)` forms.

- typing.assert_type(x, T) is a no-op at runtime; we type the result
  as type-of(x).

- Forward references as quoted strings: `def f(x: "Foo")` strips quotes
  and re-resolves the inner annotation. Also handles double-quoted and
  single-quoted forms.

- Self return type substitution: methods declared returning `Self` /
  `typing.Self` / `typing_extensions.Self` substitute to NAMED(receiver)
  at call resolution time. Enables fluent / builder pattern chaining
  to keep resolving methods after each step.

- Generic subscript stripping: annotations like `list[Foo]` strip to
  `list` for v1; `Optional[Foo]` likewise. Container element-type
  substitution still deferred (Phase 8+).

Generator additions:
- Allowlist now includes urllib, http, concurrent (top stdlib usage).
- `from X import *` re-export following at generation time. Modules
  like os.path (a star-import shim of posixpath / ntpath / genericpath)
  now have all forwarded definitions registered under their own QN.
  Iterates to a fixed point with an 8-step ceiling. typeshed tree
  expansion is bounded — only modules transitively reachable from the
  allowlist are pulled in.

Stats: 137 modules, 904 classes (2797 methods), 886 free functions
(was 114 / 753 / 794). Generated file grows from 20K to ~22K lines.

6 new test_py_lsp.c cases. All 2874 prior tests stay green.
2026-05-09 01:39:28 +02:00
Martin Vogel fa6bfd125f feat(py_lsp): stdlib registry from typeshed + generator
Phase 10 of Python LSP integration. Adds:

- scripts/gen-py-stdlib.py: walks a typeshed/stdlib checkout, parses
  each .pyi via Python's stdlib `ast` module, and emits one C source
  file populating cbm_python_stdlib_register. v1 simplifications:
    - overload stacks collapse to first signature
    - ParamSpec / TypeVarTuple / Concatenate skipped
    - version guards (if sys.version_info >= ...) flattened to union
      of branches
    - per-symbol min/max version guards not yet emitted (v1.1 follow-up)
  Module allowlist matches PYTHON_LSP_PLAN.md Phase 10 list — top
  stdlib modules by usage in indexed Python projects, skipping
  tkinter / turtle / curses / xml / email for size.

- internal/cbm/lsp/generated/python_stdlib_data.c (auto-generated,
  20,021 lines): 114 modules, 753 classes (2,385 methods), 794 free
  functions registered. Source pinned to typeshed commit
  a7912d521e16ff63caf7a8b64b9072542be36777 (recorded in header).
  Compiles cleanly under ASan + UBSan.

- lsp_all.c includes the generated file. The CBM_PYTHON_STDLIB_GENERATED
  macro disables py_lsp.c's no-op stub so the real registration runs.

4 new test_py_lsp.c cases verify resolution against the registry:
os.getcwd, collections.defaultdict constructor, pathlib.Path.exists
method via typed parameter, logging.getLogger.

Known v1 limitation captured in test comments: `import os.path` only
binds the leaf `path` in scope, so `os.path.join` style chained
attribute access on the parent module name doesn't yet resolve.
Phase 10.5 will stamp parent-module bindings.

All 2869 prior tests stay green; 4 new stdlib tests pass.
2026-05-09 00:39:21 +02:00
Martin Vogel f4f9ed47f6 feat(py_lsp): cross-file resolution + batch entry point
Phase 9 of Python LSP integration. Implements cbm_run_py_lsp_cross and
cbm_batch_py_lsp_cross, mirroring the Go LSP pattern:

- cbm_run_py_lsp_cross builds a per-call registry from caller-supplied
  CBMLSPDef[] entries (covering both file-local and cross-file
  definitions), then re-parses the source via tree_sitter_python (or
  uses cached_tree when supplied) and runs the standard
  py_lsp_process_file pipeline.
- py_register_lsp_defs translates CBMLSPDef shapes into
  CBMRegisteredType / CBMRegisteredFunc entries. Python-specific
  Protocol label is recognized as an interface-like structural type.
  Pipe-separated embedded_types and method_names_str are split into
  arena-allocated NULL-terminated arrays. Method receivers auto-create
  type entries when the type isn't otherwise registered, matching the
  Go convention.
- cbm_batch_py_lsp_cross loops the per-file entry, isolating each file
  in its own arena and copying string fields into the caller's arena
  so they outlive the file's scratch space.

3 new test_py_lsp.c cases cover cross-file method dispatch, cross-file
inheritance (Child(Base) calling self.shared from another module), and
batch processing of two interconnected files. All 2866 prior tests stay
green; 3 new cross-file tests pass.
2026-05-09 00:17:59 +02:00
Martin Vogel 329d266ed3 feat(py_lsp): decorators, super() resolution, multi-inheritance
Phases 7-8 of Python LSP integration. Adds:

- super().method() resolution: when the call's receiver is a super() proxy
  inside a class context, walk the enclosing class's embedded_types and
  resolve the attribute against the first base where it exists. Edge
  carries strategy="lsp_super" and confidence 0.88.
- Multi-inheritance dispatch already worked for the current registry shape
  (embedded_types is a list, py_lookup_attribute iterates) — added an
  explicit test to lock in the behavior.

Also fixes a Python class-body extraction gap upstream of py_lsp: the
generic extract_class_methods walker skipped any class-body child whose
node type wasn't a function_definition, which excluded everything wrapped
in a decorated_definition (i.e. every @classmethod / @staticmethod /
@property method). Patched to peek through decorated_definition for the
inner function_definition before calling resolve_method_name. This is the
root cause of the C.classmethod() / C.staticmethod() resolution miss and
the fix is independent of language-specific decorator semantics — any
language whose grammar uses decorated_definition benefits.

7 new test_py_lsp.c cases cover decorated functions, classmethod calls,
staticmethod calls, dataclass constructor + method dispatch, super() to
the right base, multi-inheritance attribute resolution on both bases, and
PEP 695-style class names without subscript handling. All 2859 prior
tests stay green; 7 new tests pass. Total: 2866 / 0.
2026-05-09 00:03:00 +02:00
Martin Vogel 1c83c5df4b feat(py_lsp): scope binding, expression typing, method dispatch
Bundles Phases 4-6 of Python LSP integration. py_lsp.c gains a real
binder + expression evaluator + call resolver mirroring go_lsp.c's
shape:

- py_register_def populates the registry from CBMFileResult.defs.
  Class/Type defs land as registered types; Method/Function defs as
  registered functions with receiver_type derived from the qualified
  name parent. Python's extract_defs records a class's bases as a
  single "(Base, Other)" string, so we split commas at depth 0,
  strip parens / generic subscripts / keyword args (metaclass=...)
  to produce usable embedded_types entries.
- py_lsp_process_file walks function/class definitions, pushing scope
  per function and binding self/cls to the enclosing class type
  (after parameter binding so the receiver wins over an unannotated
  `self` parameter).
- py_bind_parameters resolves type annotations via py_resolve_annotation:
  scope -> module-qualified registry lookup -> bare NAMED fallback.
- py_eval_expr_type covers literals (int/float/str/bool/None plus list/
  dict/set/tuple containers), names (via scope, then module symbol),
  attribute access on MODULE / NAMED, call expressions (constructors,
  module-local functions, attribute calls), comparison / boolean /
  ternary expressions, and parenthesized / binary fallthrough.
- py_lookup_attribute walks the registry's embedded_types chain with
  the shared CBM_LSP_MAX_LOOKUP_DEPTH cap so MRO chains terminate
  cleanly.
- py_emit_resolved_call writes (caller_qn, callee_qn, strategy,
  confidence) entries to result->resolved_calls. Strategies:
  lsp_direct, lsp_constructor, lsp_method, lsp_module_attr,
  lsp_module_attr_unresolved.

6 new test_py_lsp.c cases cover direct calls, simple method dispatch,
self.method, constructor-then-method, single-inheritance dispatch,
and the no-false-positive guarantee on UNKNOWN receivers.

All 2853 previous tests stay green; 6 new py_lsp tests pass.
2026-05-08 23:47:33 +02:00
Martin Vogel 40eb9afaca feat(py_lsp): bind imports into scope as MODULE / NAMED
Phase 3 of Python LSP integration. py_lsp_bind_imports walks the
import map populated from CBMFileResult.imports and binds each entry
into the root scope:

- `import X` / `import X as Y` -> bind to MODULE(X) (heuristic: when
  module_qn does not end with `.<local_name>`).
- `from X import Y` / `from X import Y as Z` -> bind to NAMED(X.Y).
  Phase 6 attribute resolution will downgrade to MODULE / class /
  function as registry lookups become available.
- `from X import *` -> recorded in the import map but not scope-bound.
  Phase 9 cross-file resolution will use the entry to walk re-exports.
- Relative imports (`from . import x`, `from ..pkg import y`) bind via
  the same heuristic; module path is preserved so cross-file matching
  can still locate the target later.

py_lsp_lookup_in_scope test hook added for unit-level scope inspection.

9 new tests in test_py_lsp.c. All 2844 prior tests stay green.
2026-05-08 23:14:19 +02:00
Martin Vogel b1a6285601 feat(lsp): py_lsp scaffold + smoke tests
Phase 2 of Python LSP integration. Adds the py_lsp module mirroring
the go_lsp / c_lsp pattern: PyLSPContext, single-file entry
(cbm_run_py_lsp), cross-file + batch shells, stdlib registration stub.

Bodies are no-ops at this phase. Subsequent phases fill in:
  Phase 3 — imports
  Phase 4 — scope binding
  Phase 5 — expression typing
  Phase 6 — attribute resolution + method dispatch
  Phase 7 — decorators
  Phase 8 — class hierarchy + generics
  Phase 9 — cross-file resolution + batch
  Phase 10 — cbm_python_stdlib_register from typeshed

Wired into cbm_extract_file alongside the existing Go and C/C++ LSP
calls. lsp_all.c #includes py_lsp.c so the CGo umbrella picks it up
without a separate translation unit.

tests/test_py_lsp.c adds 5 smoke cases: empty source, single function,
single class, syntax error recovery, imports pass-through. The helper
shape (extract_py / find_resolved / require_resolved) matches
test_go_lsp.c so future phases can grow the suite by category.

All 2839 existing tests stay green; 5 new py_lsp tests pass.
2026-05-08 23:06:02 +02:00