context-archive 1.0.0__tar.gz

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.
Files changed (52) hide show
  1. context_archive-1.0.0/LICENSE +21 -0
  2. context_archive-1.0.0/PKG-INFO +75 -0
  3. context_archive-1.0.0/README.md +45 -0
  4. context_archive-1.0.0/pyproject.toml +116 -0
  5. context_archive-1.0.0/src/context_archive/__init__.py +49 -0
  6. context_archive-1.0.0/src/context_archive/adapters/__init__.py +20 -0
  7. context_archive-1.0.0/src/context_archive/adapters/base.py +40 -0
  8. context_archive-1.0.0/src/context_archive/adapters/fastapi.py +307 -0
  9. context_archive-1.0.0/src/context_archive/adapters/pipeline.py +54 -0
  10. context_archive-1.0.0/src/context_archive/adapters/python_ast.py +845 -0
  11. context_archive-1.0.0/src/context_archive/adapters/sqlalchemy.py +188 -0
  12. context_archive-1.0.0/src/context_archive/adapters/typescript.py +394 -0
  13. context_archive-1.0.0/src/context_archive/cli.py +915 -0
  14. context_archive-1.0.0/src/context_archive/config.py +212 -0
  15. context_archive-1.0.0/src/context_archive/context/__init__.py +69 -0
  16. context_archive-1.0.0/src/context_archive/context/assembler.py +151 -0
  17. context_archive-1.0.0/src/context_archive/context/compiler.py +438 -0
  18. context_archive-1.0.0/src/context_archive/context/differ.py +331 -0
  19. context_archive-1.0.0/src/context_archive/context/reconciler.py +207 -0
  20. context_archive-1.0.0/src/context_archive/context/tokenizer.py +89 -0
  21. context_archive-1.0.0/src/context_archive/context/verifier.py +177 -0
  22. context_archive-1.0.0/src/context_archive/exceptions.py +56 -0
  23. context_archive-1.0.0/src/context_archive/git/__init__.py +17 -0
  24. context_archive-1.0.0/src/context_archive/git/state.py +152 -0
  25. context_archive-1.0.0/src/context_archive/integrations/__init__.py +15 -0
  26. context_archive-1.0.0/src/context_archive/integrations/mcp_server.py +365 -0
  27. context_archive-1.0.0/src/context_archive/lock.py +165 -0
  28. context_archive-1.0.0/src/context_archive/model/__init__.py +51 -0
  29. context_archive-1.0.0/src/context_archive/model/graph.py +239 -0
  30. context_archive-1.0.0/src/context_archive/model/ids.py +38 -0
  31. context_archive-1.0.0/src/context_archive/model/manifest.py +127 -0
  32. context_archive-1.0.0/src/context_archive/model/provenance.py +39 -0
  33. context_archive-1.0.0/src/context_archive/notes/__init__.py +25 -0
  34. context_archive-1.0.0/src/context_archive/notes/boundaries.py +67 -0
  35. context_archive-1.0.0/src/context_archive/notes/manager.py +285 -0
  36. context_archive-1.0.0/src/context_archive/py.typed +0 -0
  37. context_archive-1.0.0/src/context_archive/renderers/__init__.py +39 -0
  38. context_archive-1.0.0/src/context_archive/renderers/json_exporter.py +39 -0
  39. context_archive-1.0.0/src/context_archive/renderers/markdown.py +335 -0
  40. context_archive-1.0.0/src/context_archive/renderers/mermaid.py +78 -0
  41. context_archive-1.0.0/src/context_archive/scanner/__init__.py +24 -0
  42. context_archive-1.0.0/src/context_archive/scanner/discovery.py +89 -0
  43. context_archive-1.0.0/src/context_archive/scanner/report.py +74 -0
  44. context_archive-1.0.0/src/context_archive/scanner/walker.py +462 -0
  45. context_archive-1.0.0/src/context_archive/sdk.py +121 -0
  46. context_archive-1.0.0/src/context_archive/state/__init__.py +60 -0
  47. context_archive-1.0.0/src/context_archive/state/cache.py +89 -0
  48. context_archive-1.0.0/src/context_archive/state/compactor.py +136 -0
  49. context_archive-1.0.0/src/context_archive/state/detector.py +359 -0
  50. context_archive-1.0.0/src/context_archive/state/invalidator.py +183 -0
  51. context_archive-1.0.0/src/context_archive/state/journal.py +197 -0
  52. context_archive-1.0.0/src/context_archive/state/snapshot.py +108 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Context Archive Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: context-archive
3
+ Version: 1.0.0
4
+ Summary: Portable AI Project Context Compiler with incremental change journaling
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: ai,context,llm,ast,developer-tools,mcp,handoff
8
+ Author: Context Archive Contributors
9
+ Requires-Python: >=3.10,<3.13
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Provides-Extra: all
15
+ Provides-Extra: mcp
16
+ Provides-Extra: tiktoken
17
+ Requires-Dist: mcp (>=1.0.0,<2.0.0) ; extra == "all"
18
+ Requires-Dist: mcp (>=1.0.0,<2.0.0) ; extra == "mcp"
19
+ Requires-Dist: pathspec (>=0.12.1,<1.0.0)
20
+ Requires-Dist: pydantic (>=2.8.0,<3.0.0)
21
+ Requires-Dist: rich (>=13.7.0,<15.0.0)
22
+ Requires-Dist: tiktoken (>=0.7.0,<1.0.0) ; extra == "all"
23
+ Requires-Dist: tiktoken (>=0.7.0,<1.0.0) ; extra == "tiktoken"
24
+ Requires-Dist: tomli (>=2.0.1,<3.0.0) ; python_version < "3.11"
25
+ Requires-Dist: typer (>=0.15.0,<1.0.0)
26
+ Project-URL: Homepage, https://github.com/IT2411/context-archive
27
+ Project-URL: Repository, https://github.com/IT2411/context-archive
28
+ Description-Content-Type: text/markdown
29
+
30
+ # context-archive (`ctxa`)
31
+
32
+ **Portable AI Project Context Compiler with incremental change journaling.**
33
+
34
+ `context-archive` (`ctxa`) statically analyzes a software repository and compiles a compact, versioned, model-agnostic handoff artifact (`PROJECT_CONTEXT.md`) alongside a machine-readable canonical project graph (`.context/graph.json`) and append-only change journal (`.context/journal/YYYY-MM.jsonl`).
35
+
36
+ ## Quickstart Workflow
37
+
38
+ ```bash
39
+ # 1. Initialize .context/, snapshot-0001.json, and PROJECT_CONTEXT.md
40
+ ctxa init
41
+
42
+ # 2. Record durable architectural decisions or constraints
43
+ ctxa note "All monetary calculations must use Decimal" --type constraint --scope src/pricing.py
44
+
45
+ # 3. Record incremental code changes into the append-only JSONL journal
46
+ ctxa update -m "Add discount rounding to calculate_total"
47
+
48
+ # 4. Inspect semantic diff and downstream caller/test impact
49
+ ctxa diff --format text
50
+
51
+ # 5. Refresh portable PROJECT_CONTEXT.md and verify cryptographic freshness
52
+ ctxa handoff
53
+ ctxa verify
54
+
55
+ # 6. Compile a token-budgeted context packet for a specific task
56
+ ctxa context "Add rate limiting to POST /orders" --max-tokens 6000
57
+
58
+ # 7. Fold unincorporated journal records into a new snapshot checkpoint
59
+ ctxa compact
60
+ ```
61
+
62
+ ## Development & Verification Gates
63
+
64
+ ```bash
65
+ poetry check --lock
66
+ poetry run ruff check .
67
+ poetry run ruff format --check .
68
+ poetry run mypy src tests --strict
69
+ poetry run pytest --cov=context_archive --cov-fail-under=90
70
+ ```
71
+
72
+ ## License
73
+
74
+ MIT
75
+
@@ -0,0 +1,45 @@
1
+ # context-archive (`ctxa`)
2
+
3
+ **Portable AI Project Context Compiler with incremental change journaling.**
4
+
5
+ `context-archive` (`ctxa`) statically analyzes a software repository and compiles a compact, versioned, model-agnostic handoff artifact (`PROJECT_CONTEXT.md`) alongside a machine-readable canonical project graph (`.context/graph.json`) and append-only change journal (`.context/journal/YYYY-MM.jsonl`).
6
+
7
+ ## Quickstart Workflow
8
+
9
+ ```bash
10
+ # 1. Initialize .context/, snapshot-0001.json, and PROJECT_CONTEXT.md
11
+ ctxa init
12
+
13
+ # 2. Record durable architectural decisions or constraints
14
+ ctxa note "All monetary calculations must use Decimal" --type constraint --scope src/pricing.py
15
+
16
+ # 3. Record incremental code changes into the append-only JSONL journal
17
+ ctxa update -m "Add discount rounding to calculate_total"
18
+
19
+ # 4. Inspect semantic diff and downstream caller/test impact
20
+ ctxa diff --format text
21
+
22
+ # 5. Refresh portable PROJECT_CONTEXT.md and verify cryptographic freshness
23
+ ctxa handoff
24
+ ctxa verify
25
+
26
+ # 6. Compile a token-budgeted context packet for a specific task
27
+ ctxa context "Add rate limiting to POST /orders" --max-tokens 6000
28
+
29
+ # 7. Fold unincorporated journal records into a new snapshot checkpoint
30
+ ctxa compact
31
+ ```
32
+
33
+ ## Development & Verification Gates
34
+
35
+ ```bash
36
+ poetry check --lock
37
+ poetry run ruff check .
38
+ poetry run ruff format --check .
39
+ poetry run mypy src tests --strict
40
+ poetry run pytest --cov=context_archive --cov-fail-under=90
41
+ ```
42
+
43
+ ## License
44
+
45
+ MIT
@@ -0,0 +1,116 @@
1
+ [project]
2
+ name = "context-archive"
3
+ version = "1.0.0"
4
+ description = "Portable AI Project Context Compiler with incremental change journaling"
5
+ authors = [{ name = "Context Archive Contributors" }]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ requires-python = ">=3.10,<3.13"
9
+ keywords = ["ai", "context", "llm", "ast", "developer-tools", "mcp", "handoff"]
10
+ dependencies = [
11
+ "pydantic (>=2.8.0,<3.0.0)",
12
+ "typer (>=0.15.0,<1.0.0)",
13
+ "rich (>=13.7.0,<15.0.0)",
14
+ "pathspec (>=0.12.1,<1.0.0)",
15
+ "tomli (>=2.0.1,<3.0.0) ; python_version < '3.11'",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ tiktoken = ["tiktoken (>=0.7.0,<1.0.0)"]
20
+ mcp = ["mcp (>=1.0.0,<2.0.0)"]
21
+ all = [
22
+ "tiktoken (>=0.7.0,<1.0.0)",
23
+ "mcp (>=1.0.0,<2.0.0)",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/IT2411/context-archive"
28
+ Repository = "https://github.com/IT2411/context-archive"
29
+
30
+ [project.scripts]
31
+ ctxa = "context_archive.cli:app"
32
+
33
+ [tool.poetry]
34
+ packages = [{ include = "context_archive", from = "src" }]
35
+ include = ["src/context_archive/py.typed"]
36
+
37
+ [tool.poetry.group.dev.dependencies]
38
+ pytest = "^8.3.0"
39
+ pytest-cov = "^5.0.0"
40
+ pytest-randomly = "^3.15.0"
41
+ hypothesis = "^6.112.0"
42
+ ruff = "^0.6.0"
43
+ mypy = "^1.11.0"
44
+ pre-commit = "^3.8.0"
45
+ tomli = "^2.0.1"
46
+
47
+ [build-system]
48
+ requires = ["poetry-core>=2.0.0"]
49
+ build-backend = "poetry.core.masonry.api"
50
+
51
+ [tool.ruff]
52
+ target-version = "py310"
53
+ line-length = 100
54
+ src = ["src", "tests"]
55
+ extend-exclude = ["tests/fixtures/fixture_edge_cases"]
56
+
57
+ [tool.ruff.lint]
58
+ select = [
59
+ "E", "W", # pycodestyle
60
+ "F", # pyflakes
61
+ "I", # isort
62
+ "B", # flake8-bugbear
63
+ "C4", # flake8-comprehensions
64
+ "UP", # pyupgrade
65
+ "SIM", # flake8-simplify
66
+ "TCH", # flake8-type-checking
67
+ "PTH", # flake8-use-pathlib
68
+ "RUF", # ruff-specific
69
+ "S", # flake8-bandit (security)
70
+ ]
71
+ ignore = ["S101"]
72
+
73
+ [tool.ruff.lint.per-file-ignores]
74
+ "src/**" = ["S101", "B008"]
75
+ "tests/**" = ["S101", "S105", "S106", "S603", "S607", "B008"]
76
+
77
+ [tool.ruff.lint.flake8-type-checking]
78
+ runtime-evaluated-base-classes = ["pydantic.BaseModel"]
79
+
80
+ [tool.mypy]
81
+ python_version = "3.10"
82
+ strict = true
83
+ warn_return_any = true
84
+ warn_unused_configs = true
85
+ disallow_untyped_defs = true
86
+ disallow_any_generics = true
87
+ no_implicit_optional = true
88
+ warn_redundant_casts = true
89
+ warn_unused_ignores = true
90
+ plugins = ["pydantic.mypy"]
91
+ mypy_path = "src"
92
+ exclude = ["^tests/fixtures/"]
93
+
94
+ [tool.pytest.ini_options]
95
+ minversion = "8.0"
96
+ addopts = "-ra -q --strict-markers --strict-config --ignore=tests/fixtures"
97
+ testpaths = ["tests"]
98
+ python_files = ["test_*.py", "*_test.py", "bench_*.py"]
99
+ markers = [
100
+ "unit: fast isolated unit tests",
101
+ "integration: end-to-end filesystem and CLI workflow tests",
102
+ "golden: deterministic golden-file comparison tests",
103
+ "benchmark: performance and token budget benchmarks",
104
+ ]
105
+
106
+ [tool.coverage.run]
107
+ source = ["src/context_archive"]
108
+ branch = true
109
+
110
+ [tool.coverage.report]
111
+ fail_under = 90
112
+ exclude_lines = [
113
+ "pragma: no cover",
114
+ "if TYPE_CHECKING:",
115
+ "raise NotImplementedError",
116
+ ]
@@ -0,0 +1,49 @@
1
+ """Portable AI Project Context Compiler (context-archive / ctxa)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "1.0.0"
6
+
7
+ from context_archive.config import CtxaConfig, load_config
8
+ from context_archive.context.compiler import TaskPacket
9
+ from context_archive.context.verifier import VerificationReport
10
+ from context_archive.exceptions import (
11
+ ConfigError,
12
+ CtxaError,
13
+ CtxaWarningError,
14
+ ExitCode,
15
+ IntegrityError,
16
+ LockError,
17
+ StaleContextError,
18
+ )
19
+ from context_archive.lock import (
20
+ ContextLock,
21
+ atomic_write_bytes,
22
+ atomic_write_text,
23
+ )
24
+ from context_archive.sdk import (
25
+ ContextArchiveEngine,
26
+ HandoffResult,
27
+ UpdateResult,
28
+ )
29
+
30
+ __all__ = [
31
+ "ConfigError",
32
+ "ContextArchiveEngine",
33
+ "ContextLock",
34
+ "CtxaConfig",
35
+ "CtxaError",
36
+ "CtxaWarningError",
37
+ "ExitCode",
38
+ "HandoffResult",
39
+ "IntegrityError",
40
+ "LockError",
41
+ "StaleContextError",
42
+ "TaskPacket",
43
+ "UpdateResult",
44
+ "VerificationReport",
45
+ "__version__",
46
+ "atomic_write_bytes",
47
+ "atomic_write_text",
48
+ "load_config",
49
+ ]
@@ -0,0 +1,20 @@
1
+ """adapters subsystem for context-archive."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from context_archive.adapters.base import AdapterExtractionResult, LanguageAdapter
6
+ from context_archive.adapters.fastapi import FastApiContractExtractor
7
+ from context_archive.adapters.pipeline import extract_all_adapters
8
+ from context_archive.adapters.python_ast import PythonAstAdapter
9
+ from context_archive.adapters.sqlalchemy import SqlAlchemyEntityExtractor
10
+ from context_archive.adapters.typescript import TypeScriptAdapter
11
+
12
+ __all__ = [
13
+ "AdapterExtractionResult",
14
+ "FastApiContractExtractor",
15
+ "LanguageAdapter",
16
+ "PythonAstAdapter",
17
+ "SqlAlchemyEntityExtractor",
18
+ "TypeScriptAdapter",
19
+ "extract_all_adapters",
20
+ ]
@@ -0,0 +1,40 @@
1
+ """Protocol definitions for language and framework static analysis adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+ from context_archive.model.graph import Diagnostic, Edge, Node
12
+ from context_archive.scanner.report import ScannedFile
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AdapterExtractionResult:
17
+ """Container for Nodes, Edges, and Diagnostics produced by an adapter."""
18
+
19
+ nodes: tuple[Node, ...] = field(default=())
20
+ edges: tuple[Edge, ...] = field(default=())
21
+ diagnostics: tuple[Diagnostic, ...] = field(default=())
22
+
23
+
24
+ @runtime_checkable
25
+ class LanguageAdapter(Protocol):
26
+ """Protocol implemented by static source language analyzers."""
27
+
28
+ @property
29
+ def adapter_id(self) -> str:
30
+ """Return versioned extractor identifier (e.g., `python_ast:1.0`)."""
31
+
32
+ def can_handle(self, scanned_file: ScannedFile) -> bool:
33
+ """Return True if this adapter can analyze the given ScannedFile."""
34
+
35
+ def extract_repository(
36
+ self,
37
+ repo_root: Path,
38
+ files: tuple[ScannedFile, ...] | list[ScannedFile],
39
+ ) -> AdapterExtractionResult:
40
+ """Statically analyze all matching files in the repository."""
@@ -0,0 +1,307 @@
1
+ """FastAPI route, Depends, and Pydantic BaseModel contract extractor (SRS FR-06)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from typing import TYPE_CHECKING
7
+
8
+ from context_archive.adapters.base import AdapterExtractionResult
9
+ from context_archive.adapters.python_ast import (
10
+ compute_python_module_name,
11
+ sanitize_docstring_summary,
12
+ )
13
+ from context_archive.model.graph import Diagnostic, Edge, Node
14
+ from context_archive.model.ids import make_edge_id, make_symbol_id, normalize_posix_path
15
+ from context_archive.model.provenance import Provenance
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+ from context_archive.scanner.report import ScannedFile
21
+
22
+ EXTRACTOR_ID = "fastapi_pydantic:1.0"
23
+ HTTP_METHODS = frozenset({"get", "post", "put", "delete", "patch"})
24
+ PYDANTIC_BASE_NAMES = frozenset({"BaseModel", "SQLModel"})
25
+
26
+
27
+ def _unparse(node: ast.AST | None) -> str:
28
+ if node is None:
29
+ return ""
30
+ try:
31
+ return ast.unparse(node)
32
+ except Exception: # pragma: no cover
33
+ return ""
34
+
35
+
36
+ def _extract_route_decorator(
37
+ dec: ast.expr,
38
+ ) -> tuple[str, str, str | None, int | None] | None:
39
+ """Inspect a decorator for `@app.<method>("/path", response_model=..., status_code=...)`.
40
+
41
+ Returns `(HTTP_METHOD, route_path, response_model_name, status_code)` or `None`.
42
+ """
43
+ if not isinstance(dec, ast.Call) or not isinstance(dec.func, ast.Attribute):
44
+ return None
45
+
46
+ method_lower = dec.func.attr.lower()
47
+ if method_lower not in HTTP_METHODS:
48
+ return None
49
+
50
+ route_path = "/"
51
+ if dec.args and isinstance(dec.args[0], ast.Constant) and isinstance(dec.args[0].value, str):
52
+ route_path = dec.args[0].value
53
+
54
+ response_model: str | None = None
55
+ status_code: int | None = None
56
+ for kw in dec.keywords:
57
+ if kw.arg == "response_model":
58
+ response_model = _unparse(kw.value).split(".")[-1] or None
59
+ elif (
60
+ kw.arg == "status_code"
61
+ and isinstance(kw.value, ast.Constant)
62
+ and isinstance(kw.value.value, int)
63
+ ):
64
+ status_code = kw.value.value
65
+
66
+ return method_lower.upper(), route_path, response_model, status_code
67
+
68
+
69
+ def _extract_depends_target(default_expr: ast.expr) -> str | None:
70
+ """Return dependency callable name if `default_expr` is `Depends(callable_name)`."""
71
+ if not isinstance(default_expr, ast.Call):
72
+ return None
73
+ func_name = _unparse(default_expr.func).split(".")[-1]
74
+ if func_name != "Depends" or not default_expr.args:
75
+ return None
76
+ target = _unparse(default_expr.args[0]).split(".")[-1]
77
+ return target or None
78
+
79
+
80
+ class FastApiContractExtractor:
81
+ """Static AST extractor for FastAPI endpoints and Pydantic schemas."""
82
+
83
+ @property
84
+ def adapter_id(self) -> str:
85
+ return EXTRACTOR_ID
86
+
87
+ def can_handle(self, scanned_file: ScannedFile) -> bool:
88
+ return scanned_file.language == "python" and scanned_file.category == "source_code"
89
+
90
+ def extract_repository(
91
+ self,
92
+ repo_root: Path,
93
+ files: tuple[ScannedFile, ...] | list[ScannedFile],
94
+ *,
95
+ existing_nodes: tuple[Node, ...] | list[Node] = (),
96
+ ) -> AdapterExtractionResult:
97
+ """Extract Schema and Endpoint nodes and link EXPOSES, VALIDATES_WITH, and DEPENDS_ON."""
98
+ nodes: list[Node] = []
99
+ edges: list[Edge] = []
100
+ diagnostics: list[Diagnostic] = []
101
+
102
+ py_files = sorted(
103
+ (f for f in files if self.can_handle(f)),
104
+ key=lambda item: item.path,
105
+ )
106
+
107
+ # Lookup map of short symbol name -> Node ID for linking handlers, schemas, and dependencies
108
+ symbol_by_short_name: dict[str, str] = {}
109
+ for existing in existing_nodes:
110
+ if existing.kind in ("Function", "Method", "Class"):
111
+ symbol_by_short_name.setdefault(existing.name, existing.id)
112
+
113
+ schema_id_by_name: dict[str, str] = {}
114
+ pending_endpoints: list[tuple[str, str, str, list[str], list[str], Provenance]] = []
115
+
116
+ for scanned in py_files:
117
+ rel_posix = normalize_posix_path(scanned.path)
118
+ try:
119
+ source_text = (repo_root / rel_posix).read_text(encoding="utf-8")
120
+ tree = ast.parse(source_text, filename=rel_posix)
121
+ except (OSError, SyntaxError, ValueError):
122
+ continue
123
+
124
+ mod_name = compute_python_module_name(rel_posix)
125
+
126
+ for stmt in tree.body:
127
+ if isinstance(stmt, ast.ClassDef):
128
+ base_names = {_unparse(b).split(".")[-1] for b in stmt.bases}
129
+ if base_names & PYDANTIC_BASE_NAMES:
130
+ fields_info: list[dict[str, str]] = []
131
+ for body_item in stmt.body:
132
+ if isinstance(body_item, ast.AnnAssign) and isinstance(
133
+ body_item.target, ast.Name
134
+ ):
135
+ fname = body_item.target.id
136
+ if fname.startswith("_"):
137
+ continue
138
+ ftype = _unparse(body_item.annotation)
139
+ fdefault = (
140
+ _unparse(body_item.value) if body_item.value is not None else ""
141
+ )
142
+ fields_info.append(
143
+ {"name": fname, "type": ftype, "default": fdefault}
144
+ )
145
+
146
+ line_start = getattr(stmt, "lineno", 1)
147
+ line_end = getattr(stmt, "end_lineno", line_start) or line_start
148
+ schema_id = make_symbol_id("schema:py", rel_posix, stmt.name)
149
+ prov = Provenance(
150
+ source_path=rel_posix,
151
+ line_start=line_start,
152
+ line_end=line_end,
153
+ extractor=EXTRACTOR_ID,
154
+ evidence_kind="deterministic",
155
+ confidence="high",
156
+ )
157
+ field_summary = ", ".join(f"{f['name']}: {f['type']}" for f in fields_info)
158
+ nodes.append(
159
+ Node(
160
+ id=schema_id,
161
+ kind="Schema",
162
+ name=stmt.name,
163
+ qualified_name=f"{mod_name}.{stmt.name}",
164
+ attributes={
165
+ "fields": fields_info,
166
+ "signature": f"({field_summary})",
167
+ "docstring_summary": sanitize_docstring_summary(
168
+ ast.get_docstring(stmt)
169
+ ),
170
+ },
171
+ provenance=prov,
172
+ )
173
+ )
174
+ schema_id_by_name[stmt.name] = schema_id
175
+
176
+ elif isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef):
177
+ for dec in stmt.decorator_list:
178
+ route_info = _extract_route_decorator(dec)
179
+ if route_info is None:
180
+ continue
181
+ http_method, route_path, resp_model, status_code = route_info
182
+ line_start = getattr(stmt, "lineno", 1)
183
+ line_end = getattr(stmt, "end_lineno", line_start) or line_start
184
+ endpoint_name = f"{http_method} {route_path}"
185
+ endpoint_id = make_symbol_id(
186
+ "endpoint:py", rel_posix, f"{http_method}:{route_path}"
187
+ )
188
+ prov = Provenance(
189
+ source_path=rel_posix,
190
+ line_start=line_start,
191
+ line_end=line_end,
192
+ extractor=EXTRACTOR_ID,
193
+ evidence_kind="deterministic",
194
+ confidence="high",
195
+ )
196
+
197
+ # Inspect parameters for Pydantic request schemas and Depends(...)
198
+ schema_refs: list[str] = []
199
+ if resp_model:
200
+ schema_refs.append(resp_model)
201
+
202
+ all_args = list(stmt.args.posonlyargs) + list(stmt.args.args)
203
+ for arg in all_args:
204
+ if arg.annotation is not None:
205
+ ann_short = _unparse(arg.annotation).split(".")[-1]
206
+ if ann_short:
207
+ schema_refs.append(ann_short)
208
+
209
+ dep_refs: list[str] = []
210
+ for default_expr in list(stmt.args.defaults) + [
211
+ d for d in stmt.args.kw_defaults if d is not None
212
+ ]:
213
+ dep_target = _extract_depends_target(default_expr)
214
+ if dep_target:
215
+ dep_refs.append(dep_target)
216
+
217
+ nodes.append(
218
+ Node(
219
+ id=endpoint_id,
220
+ kind="Endpoint",
221
+ name=endpoint_name,
222
+ qualified_name=endpoint_name,
223
+ attributes={
224
+ "http_method": http_method,
225
+ "route_path": route_path,
226
+ "handler": f"{mod_name}.{stmt.name}",
227
+ "response_model": resp_model,
228
+ "status_code": status_code or 200,
229
+ "signature": (
230
+ f"[{http_method} {route_path}] -> " f"{resp_model or 'Any'}"
231
+ ),
232
+ "docstring_summary": sanitize_docstring_summary(
233
+ ast.get_docstring(stmt)
234
+ ),
235
+ },
236
+ provenance=prov,
237
+ )
238
+ )
239
+ handler_sym_id = make_symbol_id("py", rel_posix, stmt.name)
240
+ pending_endpoints.append(
241
+ (
242
+ endpoint_id,
243
+ handler_sym_id,
244
+ stmt.name,
245
+ schema_refs,
246
+ dep_refs,
247
+ prov,
248
+ )
249
+ )
250
+
251
+ # Link EXPOSES, VALIDATES_WITH, and DEPENDS_ON edges
252
+ for (
253
+ endpoint_id,
254
+ handler_sym_id,
255
+ _handler_name,
256
+ schema_refs,
257
+ dep_refs,
258
+ prov,
259
+ ) in pending_endpoints:
260
+ edges.append(
261
+ Edge(
262
+ id=make_edge_id("EXPOSES", handler_sym_id, endpoint_id),
263
+ kind="EXPOSES",
264
+ source_id=handler_sym_id,
265
+ target_id=endpoint_id,
266
+ provenance=prov,
267
+ )
268
+ )
269
+ for s_ref in sorted(set(schema_refs)):
270
+ if s_ref in schema_id_by_name:
271
+ s_id = schema_id_by_name[s_ref]
272
+ edges.append(
273
+ Edge(
274
+ id=make_edge_id("VALIDATES_WITH", endpoint_id, s_id),
275
+ kind="VALIDATES_WITH",
276
+ source_id=endpoint_id,
277
+ target_id=s_id,
278
+ provenance=prov,
279
+ )
280
+ )
281
+ edges.append(
282
+ Edge(
283
+ id=make_edge_id("VALIDATES_WITH", handler_sym_id, s_id),
284
+ kind="VALIDATES_WITH",
285
+ source_id=handler_sym_id,
286
+ target_id=s_id,
287
+ provenance=prov,
288
+ )
289
+ )
290
+ for d_ref in sorted(set(dep_refs)):
291
+ if d_ref in symbol_by_short_name:
292
+ d_id = symbol_by_short_name[d_ref]
293
+ edges.append(
294
+ Edge(
295
+ id=make_edge_id("DEPENDS_ON", endpoint_id, d_id),
296
+ kind="DEPENDS_ON",
297
+ source_id=endpoint_id,
298
+ target_id=d_id,
299
+ provenance=prov,
300
+ )
301
+ )
302
+
303
+ return AdapterExtractionResult(
304
+ nodes=tuple(nodes),
305
+ edges=tuple(edges),
306
+ diagnostics=tuple(diagnostics),
307
+ )