Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ fastapi-endpoint-detector list --app path/to/main.py --format markdown
fastapi-endpoint-detector list --app path/to/main.py --format html -o endpoints.html
```

### Machine-readable endpoint provenance

JSON and YAML inventories and analysis reports use schema version 3. Each endpoint may
include the additive, nullable `dependency_graph` field: schema-v1 bounded evidence for
the declared FastAPI dependency tree, with explicit status and limitations. Trusted
runtime extraction transports this evidence through the private protocol-v2 worker;
consumers should use the public inventory/report schema rather than that worker protocol.

## Commands

### `analyze` - Analyze Code Changes
Expand Down
104 changes: 97 additions & 7 deletions src/fastapi_endpoint_detector/analyzer/mypy_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@
ResolvedCallSite,
ResourceIdentityEvidence,
)
from fastapi_endpoint_detector.models.endpoint import Endpoint
from fastapi_endpoint_detector.models.endpoint import (
DependencyCallableKind,
DependencyResolutionStatus,
Endpoint,
)
from fastapi_endpoint_detector.models.surface_contract import CallbackRangeMode

# Type alias for line-level progress callback (file_path, line_number, symbol_name)
Expand Down Expand Up @@ -372,7 +376,7 @@ class MypyAnalyzer:
and extract precise file/line information for all references.
"""

CACHE_SCHEMA_VERSION = 16
CACHE_SCHEMA_VERSION = 18
MAX_POINTS_TO_TARGETS = 8
MAX_FACTORY_RETURNS = 64
MAX_FACTORY_STATES = 512
Expand Down Expand Up @@ -957,10 +961,74 @@ def injected_type(expression: ast.expr | None, seen: set[str]) -> str | None:
self._python_dependency_cache[cache_key] = set(found)
return found

def _runtime_dependency_seeds(self, endpoint: Endpoint) -> dict[str, int]:
"""Return only uniquely source-attested project-local runtime graph seeds."""
graph = endpoint.dependency_graph
if graph is None:
return {}
seeds: dict[str, int] = {}
canonical_root = self.source_root.resolve()
for occurrence in graph.occurrences:
if (
occurrence.resolution_status != DependencyResolutionStatus.ESTABLISHED
or occurrence.callable_kind
not in {DependencyCallableKind.FUNCTION, DependencyCallableKind.BOUND_METHOD}
or occurrence.module is None
or occurrence.qualname is None
or occurrence.source_span is None
or occurrence.display_name == "<lambda>"
or "<locals>" in occurrence.qualname
):
continue
fullname = f"{occurrence.module}.{occurrence.qualname}"
resolved = self._resolve_fullname_to_file(fullname)
if resolved is None:
continue
definition_path, definition_module = resolved
if definition_module != occurrence.module and not definition_module.endswith(
f".{occurrence.module}"
):
continue
try:
runtime_path = occurrence.source_span.file_path.resolve()
mypy_path = Path(definition_path).resolve()
runtime_path.relative_to(canonical_root)
mypy_path.relative_to(canonical_root)
except (OSError, ValueError):
continue
if runtime_path != mypy_path:
continue
dependency_tree = self._trees.get(definition_module)
if dependency_tree is None:
continue
symbol_name = occurrence.qualname.rsplit(".", maxsplit=1)[-1]
result = self._find_func_in_tree(
dependency_tree,
symbol_name,
qualified_name=occurrence.qualname,
)
if result is None or result[1] != occurrence.qualname:
continue
definition_node = getattr(result[0], "func", result[0])
definition_start, definition_end = self._get_func_lines(definition_node)
if (
occurrence.source_span.end_line < definition_start
or occurrence.source_span.start_line > definition_end
):
continue
canonical_fullname = f"{definition_module}.{result[1]}"
previous = seeds.get(canonical_fullname)
if previous is None or occurrence.depth < previous:
seeds[canonical_fullname] = occurrence.depth
return seeds

def _python_dependency_closure(self, endpoint: Endpoint) -> dict[str, int]:
"""Expand explicit FastAPI dependency annotations to the configured depth."""
"""Expand explicit and source-attested runtime dependencies to bounded depth."""
depths: dict[str, int] = {}
queue = [(fullname, 1) for fullname in self._python_dependency_fullnames(endpoint)]
initial = dict.fromkeys(self._python_dependency_fullnames(endpoint), 1)
for fullname, depth in self._runtime_dependency_seeds(endpoint).items():
initial[fullname] = min(initial.get(fullname, depth), depth)
queue = list(initial.items())
while queue:
fullname, depth = queue.pop(0)
previous = depths.get(fullname)
Expand All @@ -976,8 +1044,17 @@ def _python_dependency_closure(self, endpoint: Endpoint) -> dict[str, int]:
dependency_tree = self._trees.get(dependency_module)
if dependency_tree is None:
continue
symbol_name = fullname.rsplit(".", maxsplit=1)[-1]
dependency_result = self._find_func_in_tree(dependency_tree, symbol_name)
qualified_name = (
fullname[len(dependency_module) + 1 :]
if fullname.startswith(f"{dependency_module}.")
else fullname.rsplit(".", maxsplit=1)[-1]
)
symbol_name = qualified_name.rsplit(".", maxsplit=1)[-1]
dependency_result = self._find_func_in_tree(
dependency_tree,
symbol_name,
qualified_name=qualified_name,
)
if dependency_result is None:
continue
node, _qualified = dependency_result
Expand All @@ -1002,15 +1079,28 @@ def _python_dependency_closure(self, endpoint: Endpoint) -> dict[str, int]:

@staticmethod
def _endpoint_key(endpoint: Endpoint) -> str:
"""Key dependency data by public route and physical handler identity."""
"""Key dependency data by route, handler, and authoritative runtime graph."""
handler = endpoint.handler
graph_payload = (
None
if endpoint.dependency_graph is None
else endpoint.dependency_graph.model_dump(mode="json")
)
graph_hash = hashlib.sha256(
json.dumps(
graph_payload,
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()
return json.dumps(
[
endpoint.identifier,
str(handler.file_path.resolve()),
handler.line_number,
handler.name,
handler.module,
graph_hash,
],
separators=(",", ":"),
)
Expand Down
20 changes: 20 additions & 0 deletions src/fastapi_endpoint_detector/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,17 @@
EffectContractCoverage,
)
from fastapi_endpoint_detector.models.endpoint import (
DependencyCallableKind,
DependencyCallableStructure,
DependencyDeclarationKind,
DependencyDeclarationScope,
DependencyGraphLimitation,
DependencyGraphStatus,
DependencyResolutionStatus,
DependencySourceSpan,
Endpoint,
EndpointDependencyGraph,
EndpointDependencyOccurrence,
EndpointDiscoveryCondition,
EndpointDiscoveryStatus,
EndpointInventory,
Expand Down Expand Up @@ -104,7 +114,17 @@

__all__ = [ # noqa: RUF022 - grouped by public model domain
# Endpoint models
"DependencyCallableKind",
"DependencyCallableStructure",
"DependencyDeclarationKind",
"DependencyDeclarationScope",
"DependencyGraphLimitation",
"DependencyGraphStatus",
"DependencyResolutionStatus",
"DependencySourceSpan",
"Endpoint",
"EndpointDependencyGraph",
"EndpointDependencyOccurrence",
"EndpointDiscoveryCondition",
"EndpointDiscoveryStatus",
"EndpointInventory",
Expand Down
Loading
Loading