code-atlas-forge 1.1.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 (181) hide show
  1. code_atlas/__init__.py +6 -0
  2. code_atlas/__main__.py +6 -0
  3. code_atlas/api/__init__.py +5 -0
  4. code_atlas/api/handlers.py +1514 -0
  5. code_atlas/api/server.py +394 -0
  6. code_atlas/cli.py +2084 -0
  7. code_atlas/config.py +149 -0
  8. code_atlas/context/__init__.py +66 -0
  9. code_atlas/context/contracts.py +277 -0
  10. code_atlas/context/domain.py +760 -0
  11. code_atlas/context/engine.py +442 -0
  12. code_atlas/context/expand.py +133 -0
  13. code_atlas/context/renderers.py +71 -0
  14. code_atlas/context/tokenizer.py +37 -0
  15. code_atlas/contracts/__init__.py +38 -0
  16. code_atlas/contracts/enums.py +67 -0
  17. code_atlas/contracts/models.py +175 -0
  18. code_atlas/contracts/ports.py +70 -0
  19. code_atlas/discovery/__init__.py +25 -0
  20. code_atlas/discovery/gitignore.py +119 -0
  21. code_atlas/discovery/identity.py +52 -0
  22. code_atlas/discovery/models.py +204 -0
  23. code_atlas/discovery/scanner.py +495 -0
  24. code_atlas/domains/__init__.py +107 -0
  25. code_atlas/domains/api/__init__.py +28 -0
  26. code_atlas/domains/api/adapter.py +405 -0
  27. code_atlas/domains/api/common.py +308 -0
  28. code_atlas/domains/api/consumers.py +224 -0
  29. code_atlas/domains/api/emit.py +231 -0
  30. code_atlas/domains/api/openapi.py +398 -0
  31. code_atlas/domains/api/routes.py +582 -0
  32. code_atlas/domains/api/yaml_subset.py +683 -0
  33. code_atlas/domains/aws/__init__.py +28 -0
  34. code_atlas/domains/aws/adapter.py +382 -0
  35. code_atlas/domains/aws/common.py +261 -0
  36. code_atlas/domains/aws/configs.py +1164 -0
  37. code_atlas/domains/aws/emit.py +411 -0
  38. code_atlas/domains/aws/pyfiles.py +2220 -0
  39. code_atlas/domains/aws/sqlbridge.py +448 -0
  40. code_atlas/domains/cicd/__init__.py +50 -0
  41. code_atlas/domains/cicd/adapter.py +762 -0
  42. code_atlas/domains/cicd/classify.py +136 -0
  43. code_atlas/domains/cicd/workflow.py +504 -0
  44. code_atlas/domains/cicd/yamlmini.py +946 -0
  45. code_atlas/domains/cli_surface.py +739 -0
  46. code_atlas/domains/contracts.py +274 -0
  47. code_atlas/domains/crossresolve.py +1208 -0
  48. code_atlas/domains/evidence.py +57 -0
  49. code_atlas/domains/identity.py +149 -0
  50. code_atlas/domains/model.py +202 -0
  51. code_atlas/domains/registry.py +250 -0
  52. code_atlas/domains/resolution.py +65 -0
  53. code_atlas/domains/spark/__init__.py +31 -0
  54. code_atlas/domains/spark/adapter.py +146 -0
  55. code_atlas/domains/spark/emit.py +344 -0
  56. code_atlas/domains/spark/extract.py +108 -0
  57. code_atlas/domains/spark/sqllit.py +192 -0
  58. code_atlas/domains/spark/walk.py +1943 -0
  59. code_atlas/domains/sql/__init__.py +30 -0
  60. code_atlas/domains/sql/adapter.py +382 -0
  61. code_atlas/domains/sql/extract.py +1271 -0
  62. code_atlas/domains/sql/nodes.py +264 -0
  63. code_atlas/domains/sql/parser.py +1510 -0
  64. code_atlas/domains/sql/tokens.py +318 -0
  65. code_atlas/domains/summarize.py +126 -0
  66. code_atlas/domains/terraform/__init__.py +20 -0
  67. code_atlas/domains/terraform/adapter.py +127 -0
  68. code_atlas/domains/terraform/catalog.py +43 -0
  69. code_atlas/domains/terraform/extract.py +1277 -0
  70. code_atlas/domains/terraform/hcl.py +538 -0
  71. code_atlas/domains/terraform/refs.py +181 -0
  72. code_atlas/domains/terraform/tfjson.py +163 -0
  73. code_atlas/domains/terraform/tfvars.py +98 -0
  74. code_atlas/domains/validation.py +120 -0
  75. code_atlas/env_override.py +27 -0
  76. code_atlas/eval/__init__.py +26 -0
  77. code_atlas/eval/domain_eval.py +681 -0
  78. code_atlas/eval/ground_truth.py +688 -0
  79. code_atlas/eval/impact_truth.py +758 -0
  80. code_atlas/eval/metrics.py +178 -0
  81. code_atlas/eval/pack_value.py +610 -0
  82. code_atlas/federation/__init__.py +72 -0
  83. code_atlas/federation/federation.py +676 -0
  84. code_atlas/federation/model.py +355 -0
  85. code_atlas/federation/store.py +194 -0
  86. code_atlas/gitbin.py +48 -0
  87. code_atlas/graph/__init__.py +20 -0
  88. code_atlas/graph/builder.py +378 -0
  89. code_atlas/graph/identity.py +54 -0
  90. code_atlas/graph/queries.py +47 -0
  91. code_atlas/graph/validation.py +92 -0
  92. code_atlas/impact/__init__.py +37 -0
  93. code_atlas/impact/blast_radius.py +171 -0
  94. code_atlas/impact/contract_analyzer.py +104 -0
  95. code_atlas/impact/contracts.py +315 -0
  96. code_atlas/impact/diff_reader.py +142 -0
  97. code_atlas/impact/domain_traversal.py +768 -0
  98. code_atlas/impact/engine.py +392 -0
  99. code_atlas/impact/packs.py +128 -0
  100. code_atlas/impact/report.py +136 -0
  101. code_atlas/impact/revision_binding.py +220 -0
  102. code_atlas/impact/risk_classifier.py +83 -0
  103. code_atlas/impact/snapshot_resolver.py +136 -0
  104. code_atlas/impact/symbol_diff.py +210 -0
  105. code_atlas/impact/test_selector.py +166 -0
  106. code_atlas/indexing/__init__.py +5 -0
  107. code_atlas/indexing/cache.py +396 -0
  108. code_atlas/indexing/change_detector.py +278 -0
  109. code_atlas/indexing/coordinator.py +1032 -0
  110. code_atlas/indexing/dependency_planner.py +268 -0
  111. code_atlas/indexing/persist.py +436 -0
  112. code_atlas/indexing/reuse.py +85 -0
  113. code_atlas/install/__init__.py +7 -0
  114. code_atlas/install/cli.py +168 -0
  115. code_atlas/install/lifecycle.py +286 -0
  116. code_atlas/mcp.py +674 -0
  117. code_atlas/observability/__init__.py +19 -0
  118. code_atlas/observability/logging.py +199 -0
  119. code_atlas/parsers/__init__.py +43 -0
  120. code_atlas/parsers/contracts.py +209 -0
  121. code_atlas/parsers/engine.py +125 -0
  122. code_atlas/parsers/languages/__init__.py +1 -0
  123. code_atlas/parsers/languages/python.py +391 -0
  124. code_atlas/parsers/languages/tsx.py +28 -0
  125. code_atlas/parsers/languages/typescript.py +909 -0
  126. code_atlas/parsers/registry.py +51 -0
  127. code_atlas/parsers/tree_sitter_runtime.py +118 -0
  128. code_atlas/resolvers/__init__.py +33 -0
  129. code_atlas/resolvers/bootstrap.py +246 -0
  130. code_atlas/resolvers/capabilities.py +33 -0
  131. code_atlas/resolvers/contracts.py +133 -0
  132. code_atlas/resolvers/coordinator.py +96 -0
  133. code_atlas/resolvers/enrichment.py +318 -0
  134. code_atlas/resolvers/lsp/__init__.py +26 -0
  135. code_atlas/resolvers/lsp/adapter.py +256 -0
  136. code_atlas/resolvers/lsp/lifecycle.py +251 -0
  137. code_atlas/resolvers/lsp/spawn.py +92 -0
  138. code_atlas/resolvers/lsp/transport.py +240 -0
  139. code_atlas/resolvers/registry.py +33 -0
  140. code_atlas/resolvers/scip/__init__.py +49 -0
  141. code_atlas/resolvers/scip/decoder.py +216 -0
  142. code_atlas/resolvers/scip/importer.py +248 -0
  143. code_atlas/resolvers/scip/loader.py +90 -0
  144. code_atlas/resolvers/scip/mapper.py +271 -0
  145. code_atlas/resolvers/scip/provenance.py +211 -0
  146. code_atlas/resolvers/scip/resolver.py +289 -0
  147. code_atlas/resolvers/scip/validation.py +101 -0
  148. code_atlas/resolvers/syntactic/__init__.py +8 -0
  149. code_atlas/resolvers/syntactic/resolver.py +254 -0
  150. code_atlas/search/__init__.py +32 -0
  151. code_atlas/search/contracts.py +176 -0
  152. code_atlas/search/domain.py +561 -0
  153. code_atlas/search/engine.py +530 -0
  154. code_atlas/search/federated.py +276 -0
  155. code_atlas/search/graph.py +282 -0
  156. code_atlas/search/hybrid.py +151 -0
  157. code_atlas/search/intent.py +48 -0
  158. code_atlas/search/lexical.py +176 -0
  159. code_atlas/search/refs.py +359 -0
  160. code_atlas/search/structural.py +505 -0
  161. code_atlas/search/tokenizer.py +43 -0
  162. code_atlas/search/vector/__init__.py +35 -0
  163. code_atlas/search/vector/contracts.py +76 -0
  164. code_atlas/search/vector/embeddings.py +96 -0
  165. code_atlas/search/vector/index.py +169 -0
  166. code_atlas/search/vector/provider.py +50 -0
  167. code_atlas/storage/__init__.py +14 -0
  168. code_atlas/storage/db.py +47 -0
  169. code_atlas/storage/migrations.py +552 -0
  170. code_atlas/storage/store.py +1045 -0
  171. code_atlas/ui/__init__.py +29 -0
  172. code_atlas/ui/i18n.py +143 -0
  173. code_atlas/ui/kit.py +689 -0
  174. code_atlas/ui/wizard.py +317 -0
  175. code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
  176. code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
  177. code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
  178. code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
  179. code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
  180. code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
  181. code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,85 @@
1
+ """Reuse path do lf-0020: resultados reutilizados via cache versionado.
2
+
3
+ Contrato (grill): reuso nunca por path — a ``CacheKey`` cobre
4
+ ``content_sha256`` + idioma + versões de parser/grammar/extractor/
5
+ resolver/builder + ``config_digest``. O payload é ``ParseResult``
6
+ serializado — artefato sintático puro (símbolos, spans, relações
7
+ **não resolvidas**).
8
+
9
+ Política conservadora: só arquivos ``REUSE`` (conjunto de
10
+ dependências inalterado no plano) são servidos do cache; arquivos
11
+ ``REVALIDATE_RELATIONS`` são re-parseados — "reevaluate" = re-extração
12
+ + re-resolução. Relações nunca são copiadas prontas: o graph builder
13
+ re-resolve ``to_name`` de cada aresta sobre o universo do snapshot
14
+ novo.
15
+
16
+ Materialização: resultados reutilizados entram no ``build`` junto com
17
+ os parseados — ``persist_graph`` grava linhas **novas** amarradas ao
18
+ ``snapshot_id`` novo (isolamento lógico entre snapshots).
19
+
20
+ Cache miss (primeira indexação incremental, GC, corrupção): o arquivo
21
+ é lido e parseado de verdade — fallback correto, nunca inventa dados.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import json
28
+ from typing import TYPE_CHECKING
29
+
30
+ from code_atlas.indexing.cache import CacheKey
31
+
32
+ if TYPE_CHECKING:
33
+ from code_atlas.discovery.models import SourceFile
34
+ from code_atlas.parsers.contracts import ParserAdapter
35
+ from code_atlas.parsers.engine import ParseEngine
36
+
37
+
38
+ def config_digest(engine: ParseEngine) -> str:
39
+ """Digest da configuração efetiva que influencia o output do parse.
40
+
41
+ Cobre os limites do engine e a identidade completa dos adaptadores
42
+ (id, parser/grammar versions, extensões) — qualquer mudança de
43
+ configuração efetiva muda a chave e invalida o cache.
44
+ """
45
+ adapters = []
46
+ for lang in sorted(engine.registry.languages):
47
+ ad = engine.registry.for_language(lang)
48
+ if ad is not None:
49
+ adapters.append([
50
+ ad.adapter_id, ad.parser_version, ad.grammar_version,
51
+ list(ad.extensions),
52
+ ])
53
+ doc = {
54
+ "adapters": adapters,
55
+ "limits": [
56
+ engine.limits.max_file_size,
57
+ engine.limits.timeout_us,
58
+ engine.limits.max_diagnostics,
59
+ ],
60
+ }
61
+ blob = json.dumps(doc, sort_keys=True, separators=(",", ":"))
62
+ return hashlib.sha256(blob.encode()).hexdigest()
63
+
64
+
65
+ def cache_key_for(
66
+ f: SourceFile,
67
+ adapter: ParserAdapter,
68
+ cfg_digest: str,
69
+ ) -> CacheKey | None:
70
+ """Chave de validade do arquivo; ``None`` se sha indisponível.
71
+
72
+ ``extractor_version`` = ``adapter_id`` (identifica o extrator que
73
+ gerou o resultado); parser/grammar versions vêm do adaptador.
74
+ """
75
+ if f.sha256 is None:
76
+ return None
77
+ return CacheKey(
78
+ path=f.path,
79
+ content_sha256=f.sha256,
80
+ language=adapter.language,
81
+ parser_version=adapter.parser_version,
82
+ grammar_version=adapter.grammar_version,
83
+ extractor_version=adapter.adapter_id,
84
+ config_digest=cfg_digest,
85
+ )
@@ -0,0 +1,7 @@
1
+ """Install lifecycle — provision local environment and record a receipt.
2
+
3
+ Scope: ``project`` provisions workspace config + repository registration;
4
+ ``user`` provisions the global data dir only. Every mutation writes a
5
+ receipt under ``<data-dir>/install/`` so ``install-status`` can prove
6
+ what happened without trusting the caller's memory.
7
+ """
@@ -0,0 +1,168 @@
1
+ """`code-atlas install` — guided wizard (TTY) + flag path (JSON).
2
+
3
+ Same UX contract as `sparkforge-aws install` / `productforge install`:
4
+ the shared ui/kit wizard drives scope -> install profile -> components
5
+ -> real dry-run plan -> confirm -> apply -> doctor. Atlas-honest
6
+ differences: no host payloads and no gateway profile (a local-first
7
+ engine, not a content forge), so those steps are skipped by passing
8
+ empty vocabularies to the wizard.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from . import lifecycle
18
+
19
+ _COMPONENT_LABELS = {
20
+ "config": "Config",
21
+ "data-dir": "Data dir",
22
+ "repository": "Repository",
23
+ }
24
+ _COMPONENT_DETAILS = {
25
+ "config": "workspace config.toml (schema_version + root)",
26
+ "data-dir": "local data dir — atlas.db, receipts, manifests",
27
+ "repository": "repo registered in the index database",
28
+ }
29
+
30
+
31
+ def _tty() -> bool:
32
+ return sys.stdin.isatty() and sys.stdout.isatty()
33
+
34
+
35
+ def _install(**kw: Any) -> dict[str, Any]:
36
+ """Map the wizard's keyword contract onto the real lifecycle.
37
+
38
+ ``host``/``gateway_profile`` are accepted and ignored — the atlas
39
+ installs no host payloads and has no execution profile.
40
+ """
41
+ return lifecycle.install(
42
+ scope=kw.get("scope", "project"),
43
+ profile=kw.get("profile", "recommended"),
44
+ components=kw.get("components"),
45
+ dry_run=bool(kw.get("dry_run")),
46
+ yes=bool(kw.get("yes")),
47
+ )
48
+
49
+
50
+ def _doctor(**kw: Any) -> dict[str, Any]:
51
+ return lifecycle.doctor(scope=kw.get("scope", "project"))
52
+
53
+
54
+ def run_guided_install() -> int:
55
+ """Interactive wizard via the vendored ui/kit — arrow-key menus."""
56
+ from ..ui.kit import UIContext
57
+ from ..ui.wizard import run_wizard
58
+
59
+ return run_wizard(
60
+ forge_name="code-atlas-forge",
61
+ install_fn=_install,
62
+ doctor_fn=_doctor,
63
+ hosts_available=(),
64
+ gateway_profiles=(),
65
+ scopes=("project", "user"),
66
+ component_order=lifecycle.COMPONENTS,
67
+ component_labels=_COMPONENT_LABELS,
68
+ component_details=_COMPONENT_DETAILS,
69
+ component_defaults={
70
+ p: set(c) for p, c in lifecycle.PROFILE_COMPONENTS.items()
71
+ },
72
+ env_hosts=(),
73
+ next_hint="code-atlas doctor · code-atlas index",
74
+ ctx=UIContext.detect(),
75
+ )
76
+
77
+
78
+ def run_flag_install(
79
+ *,
80
+ scope: str,
81
+ profile: str,
82
+ components: str | None,
83
+ root: str | None,
84
+ dry_run: bool,
85
+ yes: bool,
86
+ ) -> dict[str, Any]:
87
+ """Non-interactive flag path — returns the receipt doc."""
88
+ comps = (
89
+ tuple(c.strip() for c in components.split(",") if c.strip())
90
+ if components
91
+ else None
92
+ )
93
+ return lifecycle.install(
94
+ scope=scope,
95
+ profile=profile,
96
+ components=comps,
97
+ root=Path(root) if root else None,
98
+ dry_run=dry_run,
99
+ yes=yes,
100
+ )
101
+
102
+
103
+ def _render_install_doc(doc: dict[str, Any]) -> None:
104
+ print(f"install: {doc.get('status', '?')}")
105
+ for s in doc.get("applied", []):
106
+ print(f" {s['component']:<10} {s['action']:<22} {s['target']}")
107
+ for s in doc.get("skipped", []):
108
+ print(f" skipped {s['component']:<10} {s['target']}")
109
+ for s in doc.get("unverified", []):
110
+ print(f" unverified {s['component']:<10} {s['result']}")
111
+ if doc.get("status") == "planned":
112
+ for s in doc.get("plan", []):
113
+ print(f" plan {s['component']:<10} {s['action']:<22} {s['target']}")
114
+ if doc.get("error"):
115
+ print(f" refused {doc['error'].get('kind')}: {doc['error'].get('detail')}")
116
+
117
+
118
+ def _render_check_doc(doc: dict[str, Any]) -> None:
119
+ for k, v in doc.items():
120
+ if k == "checks":
121
+ for c in v:
122
+ print(f" {c['state']:<10} {c['check']:<18} {c['target']}")
123
+ elif k != "receipt":
124
+ print(f"{k}: {v}")
125
+ if doc.get("receipt") is not None:
126
+ print(f"receipt: {json.dumps(doc['receipt'], ensure_ascii=False)}")
127
+
128
+
129
+ def run_install(args: Any) -> int:
130
+ """argparse handler — ``install|status|doctor`` actions."""
131
+ from code_atlas.observability.logging import escape_control
132
+
133
+ action = getattr(args, "action", "install")
134
+ try:
135
+ if action == "status":
136
+ doc = lifecycle.status()
137
+ elif action == "doctor":
138
+ doc = lifecycle.doctor(scope=args.scope)
139
+ else:
140
+ doc = run_flag_install(
141
+ scope=args.scope,
142
+ profile=args.profile,
143
+ components=args.components,
144
+ root=args.root,
145
+ dry_run=args.dry_run,
146
+ yes=args.yes,
147
+ )
148
+ except ValueError as exc:
149
+ print(f"error: {escape_control(str(exc))}", file=sys.stderr)
150
+ return 2
151
+
152
+ if getattr(args, "json", False):
153
+ print(json.dumps(doc, indent=2, ensure_ascii=False))
154
+ elif action == "install":
155
+ _render_install_doc(doc)
156
+ else:
157
+ _render_check_doc(doc)
158
+
159
+ if action == "install":
160
+ return 0 if doc.get("status") in ("completed", "planned") else 1
161
+ if action == "doctor":
162
+ return 0 if doc.get("status") == "healthy" else 1
163
+ return 0
164
+
165
+
166
+ def guided_install_available() -> bool:
167
+ """Bare `install` may route to the wizard only on a real TTY."""
168
+ return _tty()
@@ -0,0 +1,286 @@
1
+ """Code Atlas install lifecycle — plan, apply, receipt, doctor.
2
+
3
+ Honest local-first semantics: install provisions the local environment
4
+ (data dir, workspace config, repository registration) and records a
5
+ receipt — it never touches hosts, clouds or providers. ``dry_run``
6
+ returns the plan and writes nothing; apply requires ``yes``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sqlite3
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from code_atlas import __version__
19
+ from code_atlas.config import (
20
+ ConfigError,
21
+ config_file,
22
+ data_dir,
23
+ resolve_root,
24
+ write_config,
25
+ )
26
+
27
+ FORGE_ID = "code-atlas-forge"
28
+ SCOPES = ("project", "user")
29
+ PROFILES = ("minimal", "recommended", "full")
30
+ COMPONENTS = ("config", "data-dir", "repository")
31
+ PROFILE_COMPONENTS = {
32
+ "minimal": ("data-dir",),
33
+ "recommended": ("config", "data-dir", "repository"),
34
+ "full": ("config", "data-dir", "repository"),
35
+ }
36
+ _RECEIPT_SCHEMA = "forge/CodeAtlasInstallReceipt/v1"
37
+
38
+
39
+ def _receipt_path() -> Path:
40
+ return data_dir() / "install" / "receipt.json"
41
+
42
+
43
+ def read_receipt() -> dict[str, Any] | None:
44
+ p = _receipt_path()
45
+ if not p.exists():
46
+ return None
47
+ try:
48
+ doc: dict[str, Any] = json.loads(p.read_text(encoding="utf-8"))
49
+ return doc
50
+ except json.JSONDecodeError:
51
+ return None
52
+
53
+
54
+ def _profile_components(
55
+ profile: str, components: tuple[str, ...] | None
56
+ ) -> tuple[str, ...]:
57
+ if components:
58
+ unknown = [c for c in components if c not in COMPONENTS]
59
+ if unknown:
60
+ raise ValueError(
61
+ f"unknown components: {unknown} (valid: {list(COMPONENTS)})"
62
+ )
63
+ return components
64
+ return PROFILE_COMPONENTS.get(profile, PROFILE_COMPONENTS["recommended"])
65
+
66
+
67
+ def _plan(
68
+ *, scope: str, root: Path | None, comps: tuple[str, ...]
69
+ ) -> list[dict[str, Any]]:
70
+ """Deterministic plan — the same entries apply() will produce.
71
+
72
+ ``config``/``repository`` are workspace-scoped: under ``user`` they
73
+ are skipped with a reason instead of pretending to apply."""
74
+ steps: list[dict[str, Any]] = []
75
+ if "data-dir" in comps:
76
+ steps.append({"component": "data-dir", "action": "mkdir",
77
+ "target": str(data_dir())})
78
+ if "config" in comps:
79
+ if scope == "project":
80
+ steps.append({"component": "config",
81
+ "action": "write-if-absent",
82
+ "target": str(config_file())})
83
+ else:
84
+ steps.append({"component": "config", "action": "skipped",
85
+ "target": "workspace-scoped — needs a project root"})
86
+ if "repository" in comps:
87
+ if scope == "project":
88
+ steps.append({"component": "repository",
89
+ "action": "register",
90
+ "target": str(root)})
91
+ else:
92
+ steps.append({"component": "repository", "action": "skipped",
93
+ "target": "workspace-scoped — needs a project root"})
94
+ steps.append({"component": "receipt", "action": "write",
95
+ "target": str(_receipt_path())})
96
+ return steps
97
+
98
+
99
+ def install(
100
+ *,
101
+ scope: str = "project",
102
+ root: Path | None = None,
103
+ profile: str = "recommended",
104
+ components: tuple[str, ...] | None = None,
105
+ dry_run: bool = False,
106
+ yes: bool = False,
107
+ ) -> dict[str, Any]:
108
+ """Plan (dry_run) or apply the install. Returns a receipt doc."""
109
+ if scope not in SCOPES:
110
+ raise ValueError(f"unknown scope {scope!r} (valid: {list(SCOPES)})")
111
+ if profile not in PROFILES:
112
+ raise ValueError(
113
+ f"unknown profile {profile!r} (valid: {list(PROFILES)})")
114
+ comps = _profile_components(profile, components)
115
+ resolved = (
116
+ resolve_root(str(root) if root else None)
117
+ if scope == "project"
118
+ else None
119
+ )
120
+ if scope == "project" and resolved is not None and not resolved.is_dir():
121
+ raise ValueError(f"root {resolved} is not a directory")
122
+ steps = _plan(scope=scope, root=resolved, comps=comps)
123
+
124
+ doc: dict[str, Any] = {
125
+ "schema": _RECEIPT_SCHEMA,
126
+ "forge_id": FORGE_ID,
127
+ "version": __version__,
128
+ "operation": "install",
129
+ "status": "planned" if dry_run else "completed",
130
+ "scope": scope,
131
+ "profile": profile,
132
+ "components": list(comps),
133
+ "root": str(resolved) if resolved else None,
134
+ "data_dir": str(data_dir()),
135
+ "plan": steps,
136
+ "applied": [],
137
+ "skipped": [],
138
+ "unverified": [],
139
+ "receipt": None,
140
+ "installed_at": None,
141
+ }
142
+ if dry_run:
143
+ return doc
144
+ if not yes:
145
+ return {**doc, "status": "declined",
146
+ "error": {"kind": "CA-INSTALL-DECLINED",
147
+ "detail": "needs --yes - nothing written"}}
148
+
149
+ applied: list[dict[str, Any]] = []
150
+ skipped: list[dict[str, Any]] = []
151
+ unverified: list[dict[str, Any]] = []
152
+ for s in steps:
153
+ comp, action, target = s["component"], s["action"], s["target"]
154
+ try:
155
+ bucket, result = _apply_step(
156
+ comp, action, target,
157
+ resolved=resolved, scope=scope,
158
+ profile=profile, comps=comps,
159
+ )
160
+ except OSError as exc:
161
+ unverified.append({**s, "result": f"unverified:{exc}"})
162
+ continue
163
+ (applied if bucket == "applied" else skipped).append(
164
+ {**s, "result": result})
165
+ if action == "write" and comp == "receipt" and bucket == "applied":
166
+ doc["receipt"] = result
167
+
168
+ doc["applied"] = applied
169
+ doc["skipped"] = skipped
170
+ doc["unverified"] = unverified
171
+ doc["installed_at"] = time.time()
172
+ if unverified:
173
+ doc["status"] = "partial"
174
+ return doc
175
+
176
+
177
+ def _apply_step(
178
+ comp: str, action: str, target: str, *,
179
+ resolved: Path | None, scope: str, profile: str,
180
+ comps: tuple[str, ...],
181
+ ) -> tuple[str, str]:
182
+ """One plan step -> ("applied"|"skipped", result). May raise OSError."""
183
+ if action == "skipped":
184
+ return "skipped", target
185
+ if action == "mkdir":
186
+ Path(target).mkdir(parents=True, exist_ok=True)
187
+ return "applied", "ok"
188
+ if action == "write-if-absent":
189
+ if resolved is None:
190
+ return "skipped", "user-scope"
191
+ try:
192
+ write_config(resolved) # raises if config exists
193
+ except ConfigError as exc:
194
+ return "skipped", str(exc)
195
+ return "applied", "written"
196
+ if action == "register":
197
+ if resolved is None:
198
+ return "skipped", "user-scope"
199
+ repo_id = _register_repo(resolved)
200
+ return "applied", f"repository_id={repo_id}"
201
+ if action == "write" and comp == "receipt":
202
+ p = _receipt_path()
203
+ p.parent.mkdir(parents=True, exist_ok=True)
204
+ p.write_text(json.dumps(
205
+ {"schema": _RECEIPT_SCHEMA, "forge_id": FORGE_ID,
206
+ "version": __version__, "scope": scope,
207
+ "profile": profile, "components": list(comps),
208
+ "root": str(resolved) if resolved else None,
209
+ "installed_at": time.time()}, indent=2,
210
+ sort_keys=True), encoding="utf-8")
211
+ return "applied", str(p)
212
+ return "skipped", "no-handler"
213
+
214
+
215
+ def _register_repo(resolved: Path) -> int:
216
+ from code_atlas.discovery.identity import identity_key_for_root
217
+ from code_atlas.storage import connect, migrate
218
+ from code_atlas.storage.store import Store
219
+
220
+ conn = connect(data_dir() / "atlas.db")
221
+ try:
222
+ migrate(conn)
223
+ repo_id = Store(conn).get_or_create_repository(
224
+ identity_key_for_root(resolved), str(resolved)
225
+ )
226
+ conn.commit()
227
+ return repo_id
228
+ finally:
229
+ conn.close()
230
+
231
+
232
+ def status() -> dict[str, Any]:
233
+ r = read_receipt()
234
+ return {"forge_id": FORGE_ID, "installed": r is not None, "receipt": r}
235
+
236
+
237
+ def doctor(*, scope: str = "project", root: Path | None = None) -> dict[str, Any]:
238
+ """Health check — receipt readable, data dir writable, db openable."""
239
+ checks: list[dict[str, Any]] = []
240
+ r = read_receipt()
241
+ checks.append({"check": "receipt", "state": "ok" if r else "missing",
242
+ "target": str(_receipt_path())})
243
+ dd = data_dir()
244
+ try:
245
+ dd.mkdir(parents=True, exist_ok=True)
246
+ probe = dd / ".atlas-probe"
247
+ probe.write_text("ok")
248
+ probe.unlink()
249
+ state = "ok"
250
+ except OSError:
251
+ state = "unwritable"
252
+ checks.append({"check": "data-dir", "state": state, "target": str(dd)})
253
+ cfg = config_file()
254
+ if scope == "project":
255
+ checks.append({"check": "config",
256
+ "state": "ok" if cfg.exists() else "missing",
257
+ "target": str(cfg)})
258
+ db = dd / "atlas.db"
259
+ try:
260
+ conn = sqlite3.connect(db)
261
+ conn.execute("SELECT 1")
262
+ conn.close()
263
+ db_state = "ok"
264
+ except sqlite3.Error:
265
+ db_state = "error"
266
+ checks.append({"check": "database", "state": db_state, "target": str(db)})
267
+ checks.append({"check": "python>=3.12",
268
+ "state": "ok" if sys.version_info >= (3, 12) else "fail",
269
+ "target": sys.version.split()[0]})
270
+ bad = {"unwritable", "error", "fail"}
271
+ ok = r is not None and all(c["state"] not in bad for c in checks)
272
+ return {"forge_id": FORGE_ID, "scope": scope, "checks": checks,
273
+ "status": "healthy" if ok else "degraded"}
274
+
275
+
276
+ __all__ = [
277
+ "COMPONENTS",
278
+ "FORGE_ID",
279
+ "PROFILES",
280
+ "PROFILE_COMPONENTS",
281
+ "SCOPES",
282
+ "doctor",
283
+ "install",
284
+ "read_receipt",
285
+ "status",
286
+ ]