context-archive 1.0.0__py3-none-any.whl

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 (53) hide show
  1. context_archive/__init__.py +49 -0
  2. context_archive/adapters/__init__.py +20 -0
  3. context_archive/adapters/base.py +40 -0
  4. context_archive/adapters/fastapi.py +307 -0
  5. context_archive/adapters/pipeline.py +54 -0
  6. context_archive/adapters/python_ast.py +845 -0
  7. context_archive/adapters/sqlalchemy.py +188 -0
  8. context_archive/adapters/typescript.py +394 -0
  9. context_archive/cli.py +915 -0
  10. context_archive/config.py +212 -0
  11. context_archive/context/__init__.py +69 -0
  12. context_archive/context/assembler.py +151 -0
  13. context_archive/context/compiler.py +438 -0
  14. context_archive/context/differ.py +331 -0
  15. context_archive/context/reconciler.py +207 -0
  16. context_archive/context/tokenizer.py +89 -0
  17. context_archive/context/verifier.py +177 -0
  18. context_archive/exceptions.py +56 -0
  19. context_archive/git/__init__.py +17 -0
  20. context_archive/git/state.py +152 -0
  21. context_archive/integrations/__init__.py +15 -0
  22. context_archive/integrations/mcp_server.py +365 -0
  23. context_archive/lock.py +165 -0
  24. context_archive/model/__init__.py +51 -0
  25. context_archive/model/graph.py +239 -0
  26. context_archive/model/ids.py +38 -0
  27. context_archive/model/manifest.py +127 -0
  28. context_archive/model/provenance.py +39 -0
  29. context_archive/notes/__init__.py +25 -0
  30. context_archive/notes/boundaries.py +67 -0
  31. context_archive/notes/manager.py +285 -0
  32. context_archive/py.typed +0 -0
  33. context_archive/renderers/__init__.py +39 -0
  34. context_archive/renderers/json_exporter.py +39 -0
  35. context_archive/renderers/markdown.py +335 -0
  36. context_archive/renderers/mermaid.py +78 -0
  37. context_archive/scanner/__init__.py +24 -0
  38. context_archive/scanner/discovery.py +89 -0
  39. context_archive/scanner/report.py +74 -0
  40. context_archive/scanner/walker.py +462 -0
  41. context_archive/sdk.py +121 -0
  42. context_archive/state/__init__.py +60 -0
  43. context_archive/state/cache.py +89 -0
  44. context_archive/state/compactor.py +136 -0
  45. context_archive/state/detector.py +359 -0
  46. context_archive/state/invalidator.py +183 -0
  47. context_archive/state/journal.py +197 -0
  48. context_archive/state/snapshot.py +108 -0
  49. context_archive-1.0.0.dist-info/METADATA +75 -0
  50. context_archive-1.0.0.dist-info/RECORD +53 -0
  51. context_archive-1.0.0.dist-info/WHEEL +4 -0
  52. context_archive-1.0.0.dist-info/entry_points.txt +3 -0
  53. context_archive-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ )
@@ -0,0 +1,54 @@
1
+ """Unified multi-language and framework contract extraction pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from context_archive.adapters.base import AdapterExtractionResult
8
+ from context_archive.adapters.fastapi import FastApiContractExtractor
9
+ from context_archive.adapters.python_ast import PythonAstAdapter
10
+ from context_archive.adapters.sqlalchemy import SqlAlchemyEntityExtractor
11
+ from context_archive.adapters.typescript import TypeScriptAdapter
12
+
13
+ if TYPE_CHECKING:
14
+ from pathlib import Path
15
+
16
+ from context_archive.scanner.report import ScannedFile
17
+
18
+
19
+ def extract_all_adapters(
20
+ repo_root: Path,
21
+ files: tuple[ScannedFile, ...] | list[ScannedFile],
22
+ ) -> AdapterExtractionResult:
23
+ """Run all active language and framework adapters and link cross-stack contracts."""
24
+ py_adapter = PythonAstAdapter()
25
+ py_res = py_adapter.extract_repository(repo_root, files)
26
+
27
+ fastapi_extractor = FastApiContractExtractor()
28
+ fastapi_res = fastapi_extractor.extract_repository(
29
+ repo_root,
30
+ files,
31
+ existing_nodes=py_res.nodes,
32
+ )
33
+
34
+ sqlalchemy_extractor = SqlAlchemyEntityExtractor()
35
+ sa_res = sqlalchemy_extractor.extract_repository(repo_root, files)
36
+
37
+ combined_backend_nodes = (*py_res.nodes, *fastapi_res.nodes, *sa_res.nodes)
38
+ ts_adapter = TypeScriptAdapter()
39
+ ts_res = ts_adapter.extract_repository(
40
+ repo_root,
41
+ files,
42
+ existing_nodes=combined_backend_nodes,
43
+ )
44
+
45
+ return AdapterExtractionResult(
46
+ nodes=(*py_res.nodes, *fastapi_res.nodes, *sa_res.nodes, *ts_res.nodes),
47
+ edges=(*py_res.edges, *fastapi_res.edges, *sa_res.edges, *ts_res.edges),
48
+ diagnostics=(
49
+ *py_res.diagnostics,
50
+ *fastapi_res.diagnostics,
51
+ *sa_res.diagnostics,
52
+ *ts_res.diagnostics,
53
+ ),
54
+ )