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.
- context_archive-1.0.0/LICENSE +21 -0
- context_archive-1.0.0/PKG-INFO +75 -0
- context_archive-1.0.0/README.md +45 -0
- context_archive-1.0.0/pyproject.toml +116 -0
- context_archive-1.0.0/src/context_archive/__init__.py +49 -0
- context_archive-1.0.0/src/context_archive/adapters/__init__.py +20 -0
- context_archive-1.0.0/src/context_archive/adapters/base.py +40 -0
- context_archive-1.0.0/src/context_archive/adapters/fastapi.py +307 -0
- context_archive-1.0.0/src/context_archive/adapters/pipeline.py +54 -0
- context_archive-1.0.0/src/context_archive/adapters/python_ast.py +845 -0
- context_archive-1.0.0/src/context_archive/adapters/sqlalchemy.py +188 -0
- context_archive-1.0.0/src/context_archive/adapters/typescript.py +394 -0
- context_archive-1.0.0/src/context_archive/cli.py +915 -0
- context_archive-1.0.0/src/context_archive/config.py +212 -0
- context_archive-1.0.0/src/context_archive/context/__init__.py +69 -0
- context_archive-1.0.0/src/context_archive/context/assembler.py +151 -0
- context_archive-1.0.0/src/context_archive/context/compiler.py +438 -0
- context_archive-1.0.0/src/context_archive/context/differ.py +331 -0
- context_archive-1.0.0/src/context_archive/context/reconciler.py +207 -0
- context_archive-1.0.0/src/context_archive/context/tokenizer.py +89 -0
- context_archive-1.0.0/src/context_archive/context/verifier.py +177 -0
- context_archive-1.0.0/src/context_archive/exceptions.py +56 -0
- context_archive-1.0.0/src/context_archive/git/__init__.py +17 -0
- context_archive-1.0.0/src/context_archive/git/state.py +152 -0
- context_archive-1.0.0/src/context_archive/integrations/__init__.py +15 -0
- context_archive-1.0.0/src/context_archive/integrations/mcp_server.py +365 -0
- context_archive-1.0.0/src/context_archive/lock.py +165 -0
- context_archive-1.0.0/src/context_archive/model/__init__.py +51 -0
- context_archive-1.0.0/src/context_archive/model/graph.py +239 -0
- context_archive-1.0.0/src/context_archive/model/ids.py +38 -0
- context_archive-1.0.0/src/context_archive/model/manifest.py +127 -0
- context_archive-1.0.0/src/context_archive/model/provenance.py +39 -0
- context_archive-1.0.0/src/context_archive/notes/__init__.py +25 -0
- context_archive-1.0.0/src/context_archive/notes/boundaries.py +67 -0
- context_archive-1.0.0/src/context_archive/notes/manager.py +285 -0
- context_archive-1.0.0/src/context_archive/py.typed +0 -0
- context_archive-1.0.0/src/context_archive/renderers/__init__.py +39 -0
- context_archive-1.0.0/src/context_archive/renderers/json_exporter.py +39 -0
- context_archive-1.0.0/src/context_archive/renderers/markdown.py +335 -0
- context_archive-1.0.0/src/context_archive/renderers/mermaid.py +78 -0
- context_archive-1.0.0/src/context_archive/scanner/__init__.py +24 -0
- context_archive-1.0.0/src/context_archive/scanner/discovery.py +89 -0
- context_archive-1.0.0/src/context_archive/scanner/report.py +74 -0
- context_archive-1.0.0/src/context_archive/scanner/walker.py +462 -0
- context_archive-1.0.0/src/context_archive/sdk.py +121 -0
- context_archive-1.0.0/src/context_archive/state/__init__.py +60 -0
- context_archive-1.0.0/src/context_archive/state/cache.py +89 -0
- context_archive-1.0.0/src/context_archive/state/compactor.py +136 -0
- context_archive-1.0.0/src/context_archive/state/detector.py +359 -0
- context_archive-1.0.0/src/context_archive/state/invalidator.py +183 -0
- context_archive-1.0.0/src/context_archive/state/journal.py +197 -0
- 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
|
+
)
|