Skip to content

Commit b5a18b4

Browse files
authored
feat(python): leg 1.5 — bounded queries, addressing, and dataflow (#323)
feat(python): leg 1.5 — bounded queries, addressing, and dataflow
2 parents d4a855a + b511b43 commit b5a18b4

27 files changed

Lines changed: 10713 additions & 530 deletions

‎CHANGELOG.md‎

Lines changed: 341 additions & 2 deletions
Large diffs are not rendered by default.

‎cldk/analysis/commons/resolve.py‎

Lines changed: 326 additions & 0 deletions
Large diffs are not rendered by default.

‎cldk/analysis/commons/results.py‎

Lines changed: 405 additions & 8 deletions
Large diffs are not rendered by default.

‎cldk/analysis/python/backend.py‎

Lines changed: 1328 additions & 6 deletions
Large diffs are not rendered by default.

‎cldk/analysis/python/codeanalyzer/codeanalyzer.py‎

Lines changed: 783 additions & 54 deletions
Large diffs are not rendered by default.

‎cldk/analysis/python/neo4j/neo4j_backend.py‎

Lines changed: 933 additions & 55 deletions
Large diffs are not rendered by default.

‎cldk/analysis/python/neo4j/reconstruct.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,10 @@ def overview(row: Props) -> PyCallableOverview:
288288
``row`` is a flat ``RETURN`` projection (not a node's ``properties()``): ``signature``, ``name``,
289289
``decorators``, ``path``, ``start_line``, ``end_line``, and ``class_signature`` (the owning
290290
class via ``PY_HAS_METHOD``, or ``None`` for a module-level / nested function).
291+
292+
``path`` is projected from ``:PyCallable._module`` — the repo-relative module key, the same
293+
vocabulary :func:`class_overview` and ``locate`` speak — *not* ``:PyCallable.path``, which is
294+
the absolute path on the machine that ran the analysis.
291295
"""
292296
class_sig = row.get("class_signature")
293297
return PyCallableOverview(

‎cldk/analysis/python/python_analysis.py‎

Lines changed: 566 additions & 328 deletions
Large diffs are not rendered by default.

‎cldk/utils/exceptions/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,19 @@
1919
"""
2020

2121
from .exceptions import (
22+
AmbiguousName,
2223
CldkInitializationException,
2324
CodeanalyzerExecutionException,
25+
CodeanalyzerUsageException,
2426
GraphSchemaMismatch,
27+
SelectorNotInGraph,
2528
)
2629

2730
__all__ = [
31+
"AmbiguousName",
2832
"CodeanalyzerExecutionException",
33+
"CodeanalyzerUsageException",
2934
"CldkInitializationException",
3035
"GraphSchemaMismatch",
36+
"SelectorNotInGraph",
3137
]

‎cldk/utils/exceptions/exceptions.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,106 @@ def _describe(expected: set[str], found: set[str], missing: set[str]) -> str:
138138
return f"Graph schema mismatch: expected relationship types {sorted(expected)}, missing {sorted(missing)}. Found on the graph: {sorted(found)}. {generation}"
139139

140140

141+
class SelectorNotInGraph(ValueError):
142+
"""Raised when a scoping keyword names something the analysed application does not hold.
143+
144+
``get_symbol_table(paths=...)``, ``get_classes(module=...)`` and ``get_call_graph(roots=...)``
145+
all narrow a whole-application enumeration to a caller-supplied selection. A value in that
146+
selection which matches nothing used to contribute nothing, so a typo'd path and a module that
147+
genuinely declares no classes were the *same* empty dict — the ambiguous-empty defect the
148+
query facade exists to remove (D7). Raising makes them different answers.
149+
150+
The message names the values that matched nothing and stops there. It deliberately offers no
151+
near-miss candidates: leg 1.5's E8 puts typo-tolerant matching out of scope "not in the
152+
resolver, not in the error path", because a suggestion is a guess, and a guess presented as a
153+
correction is the confident-wrong-answer failure this design exists to prevent.
154+
155+
Subclasses :class:`ValueError` because that is what these accessors already document raising
156+
for a malformed scoping keyword (an empty selection, a ``depth`` below 1) — an unmatched value
157+
is the same class of caller error, not a new one, and a caller catching ``ValueError`` around a
158+
scoped call keeps working.
159+
160+
Attributes:
161+
kind (str): Which keyword named them — ``"paths"``, ``"module"`` or ``"roots"``.
162+
missing (list[str]): The values, as the caller wrote them, that matched nothing.
163+
requested (int): How many values the keyword carried in total, so a partial miss is
164+
visible as a partial miss.
165+
"""
166+
167+
def __init__(self, kind: str, missing: list[str], requested: int, *, detail: str | None = None) -> None:
168+
"""Initialize with the keyword, the values that missed, and how many were asked for.
169+
170+
Args:
171+
kind: The scoping keyword's name (``"paths"`` / ``"module"`` / ``"roots"``), or the
172+
thing a name-taking accessor failed to resolve (``"callable"`` / ``"value"`` /
173+
``"in_class"`` / ``"in_module"``).
174+
missing: The unmatched values, in the caller's own spelling.
175+
requested: The total number of values the keyword carried.
176+
detail: An optional sentence appended in parentheses: what the caller can do instead,
177+
or which *other* argument the miss is relative to. Never a near-miss suggestion.
178+
"""
179+
self.kind = kind
180+
self.missing = list(missing)
181+
self.requested = requested
182+
self.detail = detail
183+
self.message = f"{len(self.missing)} of {requested} {kind} not in graph: " + ", ".join(repr(m) for m in self.missing)
184+
if detail:
185+
self.message += f" ({detail})"
186+
super().__init__(self.message)
187+
188+
189+
class AmbiguousName(ValueError):
190+
"""Raised when a name the caller wrote matches more than one thing, listing every match.
191+
192+
The addressing layer (leg 1.5, E6-E8) lets a caller name a callable or a value the way it
193+
already thinks of it rather than assembling the analyzer's ``can://`` id. Names are a viable
194+
address -- 86% of this application's leaf callable names are unique -- but the remainder are
195+
framework methods (``__init__`` 238, ``write`` 220, ``create`` 214), and there picking one is a
196+
confident wrong answer with no signal to the caller. So the resolver never picks: it raises,
197+
and hands back the matches as data.
198+
199+
Like :class:`SelectorNotInGraph`, it offers no near-miss suggestions -- every string in
200+
``candidates`` genuinely matched the name as written. E8 puts typo-tolerant matching out of
201+
scope "not in the resolver, not in the error path", so there is no scoring, no edit distance,
202+
and nothing here that was not a real match.
203+
204+
``candidates`` carries **all** of them, because that is the data a caller may want to filter
205+
programmatically; ``message`` shows only the first few plus a total, because two hundred
206+
strings in a traceback is not an error message a person can act on. The message also names the
207+
keyword that narrows it -- an error that says what to do next costs nothing extra to write.
208+
209+
Subclasses :class:`ValueError` for the same reason :class:`SelectorNotInGraph` does: an
210+
unusable name is a caller error, and a caller already catching ``ValueError`` around a
211+
resolution keeps working.
212+
213+
Attributes:
214+
name (str): The name as the caller wrote it.
215+
candidates (list[str]): Every match, in a deterministic (sorted) order.
216+
kind (str): What was being resolved -- ``"callable"`` or ``"value"``.
217+
"""
218+
219+
#: How many candidates the message shows before falling back to a count.
220+
SHOWN = 5
221+
222+
def __init__(self, name: str, candidates: list[str], *, kind: str = "callable", narrow_with: str = "in_class= or in_module=") -> None:
223+
"""Initialize with the name, every match, and how a caller narrows it.
224+
225+
Args:
226+
name: The name as the caller wrote it.
227+
candidates: Every candidate that matched it.
228+
kind: What was being resolved (``"callable"`` / ``"value"``), for the message.
229+
narrow_with: The keyword(s) that would disambiguate, named in the message.
230+
"""
231+
self.name = name
232+
self.candidates = sorted(candidates)
233+
self.kind = kind
234+
shown = self.candidates[: self.SHOWN]
235+
more = len(self.candidates) - len(shown)
236+
listed = "; ".join(repr(c) for c in shown) + (f"; ... and {more} more" if more else "")
237+
self.message = f"{name!r} is ambiguous: {len(self.candidates)} {kind}s match. Narrow it with {narrow_with}. Matches: {listed}"
238+
super().__init__(self.message)
239+
240+
141241
class CodeanalyzerUsageException(Exception):
142242
"""Exception raised for incorrect CodeAnalyzer usage.
143243

0 commit comments

Comments
 (0)