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
code_atlas/cli.py ADDED
@@ -0,0 +1,2084 @@
1
+ """Interface de linha de comando do Code Atlas.
2
+
3
+ Cycle 1: ``--version``, ``doctor``, ``init``, ``index``, ``status``.
4
+ Códigos de saída consistentes:
5
+
6
+ - 0: sucesso
7
+ - 1: erro de execução (inclui índice falho)
8
+ - 2: erro de uso (argparse)
9
+ - 3: bloqueado/unsafe (path inválido, config ausente para ação exigida)
10
+
11
+ ``--json`` emite um único objeto JSON em stdout; erros vão para stderr.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import sqlite3
20
+ import sys
21
+ from collections.abc import Mapping, Sequence
22
+ from dataclasses import asdict, dataclass, replace
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ if TYPE_CHECKING:
27
+ from code_atlas.domains.crossresolve import CrossDomainResolver
28
+ from code_atlas.domains.registry import DomainRegistry
29
+ from code_atlas.federation import Federation
30
+
31
+ from code_atlas import __version__, gitbin
32
+ from code_atlas.config import (
33
+ ConfigError,
34
+ config_file,
35
+ data_dir,
36
+ resolve_root,
37
+ write_config,
38
+ )
39
+ from code_atlas.discovery.identity import identity_key_for_root
40
+ from code_atlas.discovery.scanner import probe_git
41
+ from code_atlas.indexing import IndexCoordinator, IndexOutcome
42
+ from code_atlas.observability.logging import escape_control
43
+ from code_atlas.search.structural import (
44
+ DEFAULT_LIMIT,
45
+ MAX_DEPTH,
46
+ MAX_LIMIT,
47
+ SCHEMA_VERSION,
48
+ SearchFilters,
49
+ StructuralSearch,
50
+ clamp_limit,
51
+ )
52
+ from code_atlas.storage import connect, migrate, schema_version
53
+ from code_atlas.storage.store import Store
54
+
55
+ EXIT_OK = 0
56
+ EXIT_ERROR = 1
57
+ EXIT_USAGE = 2
58
+ EXIT_UNSAFE = 3
59
+
60
+ DB_FILENAME = "atlas.db"
61
+
62
+
63
+ def db_path() -> Path:
64
+ """Banco do workspace (fora do repositório indexado)."""
65
+ return data_dir() / DB_FILENAME
66
+
67
+
68
+ def _open_db() -> sqlite3.Connection:
69
+ conn = connect(db_path())
70
+ migrate(conn)
71
+ return conn
72
+
73
+
74
+ def _resolver_factories(
75
+ target: Path,
76
+ *,
77
+ scip_path: str | None = None,
78
+ only: Sequence[str] | None = None,
79
+ ) -> tuple[Sequence[Any], list[str], dict[str, str], int]:
80
+ """Resolvers opt-in + ``retain_snapshots`` do ``config.toml``.
81
+
82
+ ``[resolvers]`` (lf-0027): ausência de config/tabela → zero
83
+ factories (syntactic only). Erros de bootstrap são devolvidos
84
+ como strings de diagnóstico — nunca levantam.
85
+ ``[indexing] retain_snapshots`` (lf-0031): 0 = ilimitado.
86
+ lf-0032: ``scip_path`` sobrescreve ``[resolvers.scip].index_path``;
87
+ ``only`` restringe os resolvers ativos a ``scip`` / ``lsp`` /
88
+ ``lsp:<nome>`` (``syntactic`` é sempre o baseline).
89
+ """
90
+ from code_atlas.resolvers.bootstrap import resolver_factories
91
+
92
+ cfg_path = config_file()
93
+ resolvers: Any = None
94
+ retain = 0
95
+ errors: list[str] = []
96
+ if cfg_path.exists():
97
+ from code_atlas.config import WorkspaceConfig
98
+
99
+ try:
100
+ cfg = WorkspaceConfig.load(cfg_path)
101
+ resolvers = cfg.resolvers
102
+ retain = cfg.retain_snapshots
103
+ except ConfigError as exc:
104
+ return (), [f"config load failed: {exc}"], {}, 0
105
+ table: dict[str, Any] = dict(resolvers or {})
106
+ if scip_path:
107
+ scip = dict(table.get("scip") or {})
108
+ scip["index_path"] = scip_path
109
+ table["scip"] = scip
110
+ if only:
111
+ names = {n.strip() for n in only if n.strip()}
112
+ known_lsp = set(
113
+ ((resolvers or {}).get("lsp", {}) or {}).get("servers", {})
114
+ or {}
115
+ ) if isinstance((resolvers or {}).get("lsp"), dict) else set()
116
+ unknown = sorted(
117
+ n for n in names
118
+ if n not in ("syntactic", "scip", "lsp")
119
+ and not (n.startswith("lsp:") and n[4:] in known_lsp)
120
+ )
121
+ for u in unknown:
122
+ errors.append(f"--resolver: resolver desconhecido '{u}'")
123
+ if "scip" not in names:
124
+ table.pop("scip", None)
125
+ lsp = table.get("lsp")
126
+ if isinstance(lsp, dict) and "lsp" not in names:
127
+ wanted = {n[4:] for n in names if n.startswith("lsp:")}
128
+ servers = dict(lsp.get("servers") or {})
129
+ if wanted:
130
+ lsp = dict(lsp)
131
+ lsp["servers"] = {
132
+ k: v for k, v in servers.items() if k in wanted
133
+ }
134
+ table["lsp"] = lsp
135
+ else:
136
+ table.pop("lsp", None)
137
+ versions: dict[str, str] = {}
138
+ factories, boot_errors = resolver_factories(table or None, target, versions)
139
+ errors.extend(boot_errors)
140
+ return tuple(factories), errors, versions, retain
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class Check:
145
+ """Uma verificação do ``doctor``."""
146
+
147
+ name: str
148
+ value: str
149
+ ok: bool
150
+ detail: str = ""
151
+
152
+
153
+ def _probe_git() -> Check:
154
+ if gitbin.git_binary() is None:
155
+ return Check("git", "absent", True, "git binary not found; degraded discovery mode")
156
+ version = gitbin.git_version()
157
+ if not version:
158
+ return Check("git", "error", True, "git --version failed")
159
+ return Check("git", version, True)
160
+
161
+
162
+ def _probe_fts5() -> bool:
163
+ try:
164
+ con = sqlite3.connect(":memory:")
165
+ try:
166
+ con.execute("CREATE VIRTUAL TABLE t USING fts5(x)")
167
+ finally:
168
+ con.close()
169
+ return True
170
+ except sqlite3.Error:
171
+ return False
172
+
173
+
174
+ def _first_writable_ancestor(path: Path) -> tuple[Path, bool]:
175
+ """Primeiro ancestral existente de ``path`` e se é gravável."""
176
+ current = path
177
+ while not current.exists() and current != current.parent:
178
+ current = current.parent
179
+ return current, current.exists() and os.access(current, os.W_OK)
180
+
181
+
182
+ def run_doctor(args: argparse.Namespace) -> int:
183
+ """Executa verificações reais de ambiente. Sai 0 se não houver falhas."""
184
+ checks: list[Check] = []
185
+
186
+ py_ok = sys.version_info >= (3, 12)
187
+ checks.append(
188
+ Check(
189
+ "python",
190
+ sys.version.split()[0],
191
+ py_ok,
192
+ "" if py_ok else "requires >= 3.12",
193
+ )
194
+ )
195
+ checks.append(Check("sqlite", sqlite3.sqlite_version, True))
196
+ checks.append(Check("fts5", "available" if _probe_fts5() else "unavailable", _probe_fts5()))
197
+ checks.append(_probe_git())
198
+
199
+ cfg = config_file()
200
+ checks.append(
201
+ Check(
202
+ "config",
203
+ str(cfg),
204
+ True,
205
+ "exists" if cfg.exists() else "missing (run `code-atlas init`)",
206
+ )
207
+ )
208
+ dd = data_dir()
209
+ ancestor, writable = _first_writable_ancestor(dd)
210
+ checks.append(
211
+ Check(
212
+ "data_dir",
213
+ str(dd),
214
+ writable,
215
+ f"writable via {ancestor}" if writable else f"not writable (checked {ancestor})",
216
+ )
217
+ )
218
+
219
+ failed = [c for c in checks if not c.ok]
220
+ if args.json:
221
+ payload: dict[str, Any] = {
222
+ "tool": "code-atlas",
223
+ "version": __version__,
224
+ "healthy": not failed,
225
+ "checks": [c.__dict__ for c in checks],
226
+ }
227
+ print(json.dumps(payload, indent=2))
228
+ else:
229
+ width = max(len(c.name) for c in checks)
230
+ for c in checks:
231
+ mark = "ok" if c.ok else "FAIL"
232
+ suffix = f" {c.detail}" if c.detail else ""
233
+ print(escape_control(
234
+ f"{c.name:<{width}} {mark:<4} {c.value}{suffix}"))
235
+ if failed:
236
+ print(f"\n{len(failed)} check(s) failed", file=sys.stderr)
237
+ return EXIT_ERROR if failed else EXIT_OK
238
+
239
+
240
+ def run_init(args: argparse.Namespace) -> int:
241
+ """Cria config, data dir e registra o repositório no índice local."""
242
+ root = resolve_root(args.root)
243
+ if not root.is_dir():
244
+ print(f"error: root {root} is not a directory", file=sys.stderr)
245
+ return EXIT_UNSAFE
246
+ try:
247
+ cfg = write_config(root, force=args.force)
248
+ except ConfigError as exc:
249
+ print(f"error: {escape_control(str(exc))}", file=sys.stderr)
250
+ return EXIT_ERROR
251
+ conn = _open_db()
252
+ try:
253
+ # lf-0021: identidade lógica via probe git — sem scan completo.
254
+ repo_id = Store(conn).get_or_create_repository(
255
+ identity_key_for_root(root), str(root)
256
+ )
257
+ conn.commit()
258
+ finally:
259
+ conn.close()
260
+ payload = {
261
+ "config": str(cfg),
262
+ "root": str(root),
263
+ "data_dir": str(data_dir()),
264
+ "database": str(db_path()),
265
+ "repository_id": repo_id,
266
+ }
267
+ if args.json:
268
+ print(json.dumps(payload))
269
+ else:
270
+ for k, v in payload.items():
271
+ print(f"{k}: {escape_control(str(v))}")
272
+ return EXIT_OK
273
+
274
+
275
+ def run_status(args: argparse.Namespace) -> int:
276
+ """Relata estado do índice para a raiz resolvida (funciona pré-index)."""
277
+ root = resolve_root(args.root)
278
+ cfg = config_file()
279
+ db_file = db_path()
280
+ initialized = db_file.exists()
281
+
282
+ payload: dict[str, object] = {
283
+ "tool": "code-atlas",
284
+ "version": __version__,
285
+ "root": str(root),
286
+ "config": str(cfg),
287
+ "config_exists": cfg.exists(),
288
+ "database": str(db_file),
289
+ "initialized": initialized,
290
+ }
291
+ git_info = probe_git(root) if root.is_dir() else None
292
+ git_payload: dict[str, object] | None = None
293
+ if git_info is not None:
294
+ git_payload = {
295
+ "available": git_info.available,
296
+ "is_repo": git_info.is_repo,
297
+ "branch": git_info.branch,
298
+ "head_sha": git_info.head_sha,
299
+ "dirty": git_info.dirty,
300
+ "is_worktree": git_info.is_worktree,
301
+ }
302
+ payload["git"] = git_payload
303
+
304
+ if not initialized:
305
+ payload["snapshot"] = None
306
+ payload["counts"] = {"source_files": 0, "snapshots": 0, "index_jobs": 0}
307
+ payload["schema_version"] = 0
308
+ if not args.json:
309
+ print(f"root: {root}")
310
+ print("state: not initialized — run `code-atlas init` or `code-atlas index`")
311
+ if git_info and git_info.is_repo:
312
+ print(f"git: {git_info.branch or 'detached'}@{git_info.head_sha[:12]}")
313
+ else:
314
+ print(json.dumps(payload))
315
+ return EXIT_OK
316
+
317
+ conn = _open_db()
318
+ try:
319
+ store = Store(conn)
320
+ payload["schema_version"] = schema_version(conn)
321
+ row = conn.execute(
322
+ "SELECT id FROM repositories WHERE root_handle = ?", (str(root),)
323
+ ).fetchone()
324
+ repo_id: int | None = int(row["id"]) if row else None
325
+ payload["repository_id"] = repo_id
326
+ count_queries = {
327
+ "source_files": "SELECT COUNT(*) AS c FROM source_files",
328
+ "snapshots": "SELECT COUNT(*) AS c FROM snapshots",
329
+ "index_jobs": "SELECT COUNT(*) AS c FROM index_jobs",
330
+ }
331
+ payload["counts"] = {
332
+ name: int(conn.execute(sql).fetchone()["c"])
333
+ for name, sql in count_queries.items()
334
+ }
335
+ snap = store.latest_ready_snapshot(repo_id) if repo_id else None
336
+ snap_payload: dict[str, object] | None = None
337
+ if snap is not None:
338
+ snap_payload = {
339
+ "id": snap.id,
340
+ "status": str(snap.status),
341
+ "worktree_digest": snap.worktree_digest,
342
+ "git_ref": snap.git_ref,
343
+ "commit_sha": snap.commit_sha,
344
+ "parent_snapshot_id": snap.parent_snapshot_id,
345
+ "created_at": snap.created_at,
346
+ "files": store.inventory_count(snap.id) if snap.id else 0,
347
+ }
348
+ payload["snapshot"] = snap_payload
349
+ finally:
350
+ conn.close()
351
+
352
+ if args.json:
353
+ print(json.dumps(payload))
354
+ else:
355
+ print(f"root: {root}")
356
+ print(f"schema: v{payload['schema_version']}")
357
+ if git_payload:
358
+ print(f"git: {git_payload['branch'] or 'detached'}"
359
+ f"@{str(git_payload['head_sha'])[:12]} "
360
+ f"{'(dirty)' if git_payload['dirty'] else '(clean)'}")
361
+ if snap_payload:
362
+ print(f"snapshot: #{snap_payload['id']} {snap_payload['status']}"
363
+ f" — {snap_payload['files']} files")
364
+ else:
365
+ print("snapshot: none (run `code-atlas index`)")
366
+ return EXIT_OK
367
+
368
+
369
+ def run_index(args: argparse.Namespace) -> int:
370
+ """Indexa o inventário do diretório-alvo via IndexCoordinator.
371
+
372
+ Path arg com segmento ``..`` é rejeitado (hardening): para indexar um
373
+ diretório pai, passe o caminho absoluto diretamente.
374
+ """
375
+ if args.path and ".." in Path(args.path).parts:
376
+ print("error: '..' traversal is not allowed in PATH", file=sys.stderr)
377
+ return EXIT_UNSAFE
378
+ target = Path(args.path).expanduser().resolve() if args.path else resolve_root(args.root)
379
+ if target == target.parent:
380
+ print("error: refusing to index filesystem root", file=sys.stderr)
381
+ return EXIT_UNSAFE
382
+ if not target.is_dir():
383
+ print(f"error: {escape_control(str(target))} is not a directory",
384
+ file=sys.stderr)
385
+ return EXIT_UNSAFE
386
+ conn = _open_db()
387
+ try:
388
+ # Resolvers opt-in (lf-0027): [resolvers] do config.toml —
389
+ # erros de bootstrap viram diagnóstico no payload, não crash.
390
+ # lf-0032: --scip sobrescreve o index_path; --resolver restringe
391
+ # os resolvers ativos — nome desconhecido é erro tipado (nunca
392
+ # uma flag que silenciosamente não faz nada).
393
+ factories, boot_errors, res_versions, retain, fatal = (
394
+ _index_resolvers(target, args))
395
+ if fatal:
396
+ conn.close()
397
+ return _emit_error(args, fatal)
398
+ # Domínio opt-in (lf-0123, Cycle 7 Phase 15): ``--domains``
399
+ # monta o registry de adapters built-in; ``--cross-domain``
400
+ # injeta o resolver. Default = flags ausentes → ``None`` →
401
+ # comportamento byte a byte anterior (adapters OFF).
402
+ dom_reg, cross_res, dom_err = _index_domains(target, args)
403
+ if dom_err:
404
+ conn.close()
405
+ return _emit_error(args, dom_err)
406
+ coord = IndexCoordinator(
407
+ conn, resolver_factories=factories,
408
+ resolver_versions=res_versions,
409
+ snapshot_retention=retain,
410
+ domain_registry=dom_reg,
411
+ cross_resolver=cross_res)
412
+ # lf-0022: incremental é o default — ``--full`` é o escape
413
+ # (force desliga o caminho incremental internamente).
414
+ # lf-0141: ``--verbose`` liga o callback de progresso — linhas
415
+ # ``[index]`` em stderr, stdout fica livre para ``--json``.
416
+ outcome: IndexOutcome = coord.index(
417
+ target, force=args.full, incremental=True,
418
+ progress=_index_progress
419
+ if getattr(args, "verbose", False) else None)
420
+ plan_counts = (
421
+ coord.last_plan.counts() if coord.last_plan is not None else {}
422
+ )
423
+ finally:
424
+ conn.close()
425
+ payload = {
426
+ "tool": "code-atlas",
427
+ "version": __version__,
428
+ "schema_version": SCHEMA_VERSION,
429
+ "root": str(target),
430
+ "snapshot_id": outcome.snapshot_id,
431
+ "job_id": outcome.job_id,
432
+ "status": outcome.status,
433
+ "files_indexed": outcome.files_indexed,
434
+ "secrets_skipped": outcome.secrets_skipped,
435
+ "warnings": list(outcome.warnings),
436
+ "noop": outcome.noop,
437
+ "error": outcome.error,
438
+ "stats": outcome.stats,
439
+ "plan": plan_counts,
440
+ "diagnostics": boot_errors,
441
+ "domains": (
442
+ sorted(d.domain_id for d in dom_reg.enabled())
443
+ if dom_reg is not None else []
444
+ ),
445
+ "cross_domain": cross_res is not None,
446
+ }
447
+ if args.json:
448
+ print(json.dumps(payload))
449
+ else:
450
+ _print_index_human(outcome)
451
+ return EXIT_OK if outcome.status in ("success", "noop") else EXIT_ERROR
452
+
453
+
454
+ def _index_progress(event: str, fields: Mapping[str, Any]) -> None:
455
+ """``index --verbose``: uma linha ``[index] <evento> k=v …`` em stderr.
456
+
457
+ stdout fica reservado ao payload — ``--json`` continua parseável
458
+ com o progresso ligado. Nenhum campo carrega paths nem segredos —
459
+ e o escape de controles (lf-0153) garante que isso continue
460
+ verdade mesmo se um campo futuro carregar texto não-confiável:
461
+ ``\\n``/ANSI nunca forjam linhas ou sequências de terminal.
462
+ """
463
+ parts = " ".join(f"{k}={v}" for k, v in fields.items())
464
+ line = escape_control(f"[index] {event} {parts}").rstrip()
465
+ print(line, file=sys.stderr)
466
+
467
+
468
+ def _index_domains(
469
+ target: Path, args: argparse.Namespace,
470
+ ) -> tuple[DomainRegistry | None, CrossDomainResolver | None, str]:
471
+ """Flags de domínio do ``index`` → (registry, cross_resolver, fatal).
472
+
473
+ ``--domains`` aceita ids separados por vírgula ou ``all``;
474
+ ``--cross-domain`` exige ``--domains`` (sem rows de domínio o plano
475
+ do resolver sai vazio — recusar é mais honesto que indexar uma
476
+ versão de analisador que não faz nada).
477
+ """
478
+ spec = getattr(args, "domains", None)
479
+ cross = bool(getattr(args, "cross_domain", False))
480
+ if not spec:
481
+ if cross:
482
+ return None, None, "--cross-domain requires --domains"
483
+ return None, None, ""
484
+ from code_atlas.domains.cli_surface import select_domain_registry
485
+
486
+ reg, err = select_domain_registry(spec, target)
487
+ if err or reg is None:
488
+ return None, None, err or "--domains: registry build failed"
489
+ resolver = None
490
+ if cross:
491
+ from code_atlas.domains.crossresolve import CrossDomainResolver
492
+
493
+ resolver = CrossDomainResolver()
494
+ return reg, resolver, ""
495
+
496
+
497
+ def _print_index_human(outcome: IndexOutcome) -> None:
498
+ if outcome.status == "success":
499
+ print(f"snapshot #{outcome.snapshot_id} READY — "
500
+ f"{outcome.files_indexed} inventory files")
501
+ s = outcome.stats
502
+ if s.get("occurrences") or s.get("relations"):
503
+ print(f"graph: {s.get('symbols_new', 0)} symbols, "
504
+ f"{s.get('occurrences', 0)} occurrences, "
505
+ f"{s.get('relations', 0)} relations "
506
+ f"({s.get('files_failed', 0)} files failed)")
507
+ if s.get("domain_adapters"):
508
+ dom = {k: v for k, v in s.items()
509
+ if k.startswith("domain_") and k != "domain_adapters"}
510
+ print(f"domains: {s['domain_adapters']} adapter(s) — "
511
+ + ", ".join(f"{k}={v}" for k, v in sorted(dom.items())))
512
+ elif outcome.status == "noop":
513
+ print(f"snapshot #{outcome.snapshot_id} unchanged — no-op index")
514
+ else:
515
+ print(f"index failed: {escape_control(outcome.error)}",
516
+ file=sys.stderr)
517
+ if outcome.secrets_skipped:
518
+ print(f"secrets excluded: {outcome.secrets_skipped}")
519
+ for w in outcome.warnings:
520
+ print(f"warning: {w}", file=sys.stderr)
521
+
522
+
523
+ def _index_resolvers(
524
+ target: Path, args: argparse.Namespace,
525
+ ) -> tuple[Sequence[Any], list[str], dict[str, str], int, str]:
526
+ """Flags de resolver do ``index`` → factories/versions/retain.
527
+
528
+ ``fatal`` não-vazio = erro tipado de flag (resolver desconhecido)
529
+ — o caller aborta sem indexar.
530
+ """
531
+ only: list[str] = []
532
+ for r in args.resolver or ():
533
+ only.extend(p for p in r.split(",") if p.strip())
534
+ factories, errors, versions, retain = _resolver_factories(
535
+ target, scip_path=args.scip, only=only or None)
536
+ bad = "; ".join(
537
+ e for e in errors if e.startswith("--resolver:"))
538
+ return factories, errors, versions, retain, bad
539
+
540
+
541
+ def run_semantic_status(args: argparse.Namespace) -> int:
542
+ """lf-0032: resolvers installed/enabled/used + cobertura do READY.
543
+
544
+ Lê só o snapshot READY persistido — nunca inventa cobertura.
545
+ ``installed``/``enabled`` vêm de ``describe_resolvers`` (read-only:
546
+ nenhum server LSP é spawnado); ``used`` vem de
547
+ ``relations.resolver`` — a única fonte autoritativa de uso real.
548
+ """
549
+ target = (
550
+ Path(args.path).expanduser().resolve()
551
+ if args.path else resolve_root(args.root)
552
+ )
553
+ conn = _open_db()
554
+ try:
555
+ payload = _semantic_payload(conn, target)
556
+ finally:
557
+ conn.close()
558
+ return _emit_page(args, payload)
559
+
560
+
561
+ def _semantic_payload(
562
+ conn: sqlite3.Connection, target: Path
563
+ ) -> dict[str, Any]:
564
+ """Monta o payload do ``semantic-status`` lendo só o READY."""
565
+ search = StructuralSearch(conn)
566
+ repo_id = search.repo_for_root(str(target))
567
+ snap = (
568
+ search.resolve_snapshot(repo_id, None)[0]
569
+ if repo_id is not None else None
570
+ )
571
+ coverage, used = _resolver_usage(conn, snap)
572
+ reports, boot_errors = _resolver_reports(target, used)
573
+ analyzer_versions = dict(snap.analyzer_versions) if snap else {}
574
+ last_job: dict[str, Any] = {}
575
+ if repo_id is not None:
576
+ row = conn.execute(
577
+ "SELECT counts FROM index_jobs WHERE repository_id = ? "
578
+ "AND status IN ('success','noop','failed') "
579
+ "ORDER BY id DESC LIMIT 1",
580
+ (repo_id,),
581
+ ).fetchone()
582
+ if row is not None:
583
+ last_job = json.loads(row["counts"] or "{}")
584
+ sem_keys = ("semantic_attempted", "semantic_enriched",
585
+ "semantic_diagnostics")
586
+ payload: dict[str, Any] = {
587
+ "tool": "code-atlas",
588
+ "version": __version__,
589
+ "schema_version": SCHEMA_VERSION,
590
+ "root": str(target),
591
+ "snapshot_id": snap.id if snap else None,
592
+ "indexed": snap is not None,
593
+ "resolvers": reports,
594
+ "coverage": {
595
+ "resolved": coverage.get("resolved", 0),
596
+ "ambiguous": coverage.get("ambiguous", 0),
597
+ "unresolved": coverage.get("unresolved", 0),
598
+ "external": coverage.get("external", 0),
599
+ "total": sum(coverage.values()),
600
+ },
601
+ "semantic": {k: last_job.get(k, 0) for k in sem_keys},
602
+ "revision": {
603
+ "commit_sha": snap.commit_sha if snap else None,
604
+ "scip_index_sha": analyzer_versions.get("resolver:scip"),
605
+ },
606
+ "diagnostics": boot_errors,
607
+ "_human": [],
608
+ }
609
+ for rep in reports:
610
+ flags = "/".join(
611
+ k for k in ("installed", "enabled", "used") if rep[k])
612
+ payload["_human"].append(
613
+ f"{rep['id']}: {flags or 'none'} edges={rep['edges']}")
614
+ cov = payload["coverage"]
615
+ payload["_human"].append(
616
+ f"coverage: resolved={cov['resolved']} "
617
+ f"ambiguous={cov['ambiguous']} "
618
+ f"unresolved={cov['unresolved']} external={cov['external']}")
619
+ for e in boot_errors:
620
+ payload["_human"].append(f"diagnostic: {e}")
621
+ return payload
622
+
623
+
624
+ def _resolver_usage(
625
+ conn: sqlite3.Connection, snap: Any
626
+ ) -> tuple[dict[str, int], dict[str, dict[str, int]]]:
627
+ """Cobertura por status + uso por resolver no snapshot READY."""
628
+ coverage: dict[str, int] = {}
629
+ used: dict[str, dict[str, int]] = {}
630
+ if snap is None or snap.id is None:
631
+ return coverage, used
632
+ for r in conn.execute(
633
+ "SELECT resolver, resolution_status, COUNT(*) AS c "
634
+ "FROM relations WHERE snapshot_id = ? "
635
+ "GROUP BY resolver, resolution_status",
636
+ (snap.id,),
637
+ ):
638
+ coverage[r["resolution_status"]] = (
639
+ coverage.get(r["resolution_status"], 0) + r["c"])
640
+ used.setdefault(r["resolver"], {}).setdefault(
641
+ r["resolution_status"], 0)
642
+ used[r["resolver"]][r["resolution_status"]] += r["c"]
643
+ return coverage, used
644
+
645
+
646
+ def _resolver_reports(
647
+ target: Path, used: dict[str, dict[str, int]]
648
+ ) -> tuple[list[dict[str, Any]], list[str]]:
649
+ """``describe_resolvers`` + marcação de uso real por resolver."""
650
+ from code_atlas.resolvers.bootstrap import describe_resolvers
651
+
652
+ table: Any = None
653
+ cfg_path = config_file()
654
+ if cfg_path.exists():
655
+ from code_atlas.config import WorkspaceConfig
656
+
657
+ try:
658
+ table = WorkspaceConfig.load(cfg_path).resolvers
659
+ except ConfigError:
660
+ table = None
661
+ reports, errors = describe_resolvers(table, target)
662
+ for rep in reports:
663
+ rid = str(rep["id"])
664
+ by_status = dict(used.get(rid, {}))
665
+ if rid == "syntactic-scope-v1":
666
+ # arestas sintáticas carregam o parser-id como resolver
667
+ # (ex.: tree-sitter-python) — tudo que não é resolver
668
+ # externo pertence ao tier sintático baseline.
669
+ for k, v in used.items():
670
+ if k == rid:
671
+ continue
672
+ if not k.startswith("scip") and not k.startswith("lsp:"):
673
+ for st, c in v.items():
674
+ by_status[st] = by_status.get(st, 0) + c
675
+ rep["used"] = bool(by_status)
676
+ rep["edges"] = sum(by_status.values())
677
+ rep["by_status"] = by_status
678
+ return reports, errors
679
+
680
+
681
+ def _search_context(
682
+ args: argparse.Namespace,
683
+ ) -> tuple[
684
+ sqlite3.Connection | None,
685
+ int | None,
686
+ int | None,
687
+ dict[str, Any] | None,
688
+ str,
689
+ ]:
690
+ """Resolve (conn, repo_id, snapshot, repo_info) para read-only.
691
+
692
+ Erros são impressos em stderr + JSON de erro; retorna tupla com
693
+ ``None``s e mensagem quando indisponível — caller sai EXIT_ERROR.
694
+ """
695
+ root = resolve_root(args.root)
696
+ conn = _open_db()
697
+ search = StructuralSearch(conn)
698
+ root_handle = str(root)
699
+ repo_id = search.repo_for_root(root_handle)
700
+ if repo_id is None:
701
+ conn.close()
702
+ return None, None, None, None, f"no indexed repository at {root_handle}"
703
+ snap, err = search.resolve_snapshot(repo_id, args.snapshot)
704
+ if err or snap is None or snap.id is None:
705
+ conn.close()
706
+ return None, None, None, None, err or "snapshot missing id"
707
+ repo_info = search.repository_info(repo_id) or {"id": repo_id}
708
+ return conn, repo_id, snap.id, repo_info, ""
709
+
710
+
711
+ def _emit_error(args: argparse.Namespace, msg: str) -> int:
712
+ if args.json:
713
+ print(json.dumps({"tool": "code-atlas", "version": __version__,
714
+ "error": msg, "schema_version": SCHEMA_VERSION}))
715
+ else:
716
+ print(f"error: {escape_control(msg)}", file=sys.stderr)
717
+ return EXIT_ERROR
718
+
719
+
720
+ def _emit_page(args: argparse.Namespace, payload: dict[str, Any]) -> int:
721
+ """--json: objeto único parseável. Humano: linhas pré-formatadas."""
722
+ if args.json:
723
+ payload.pop("_human", None)
724
+ print(json.dumps(payload))
725
+ else:
726
+ for line in payload.pop("_human", []):
727
+ print(escape_control(line))
728
+ if "total" in payload:
729
+ print(
730
+ f"-- total={payload['total']} "
731
+ f"truncated={payload.get('truncated', False)}"
732
+ )
733
+ return EXIT_OK
734
+
735
+
736
+ def run_search(args: argparse.Namespace) -> int:
737
+ conn, repo_id, snap_id, repo_info, err = _search_context(args)
738
+ if err:
739
+ return _emit_error(args, err)
740
+ assert (
741
+ conn is not None and repo_id is not None and snap_id is not None
742
+ )
743
+ strategy = getattr(args, "strategy", "structural") or "structural"
744
+ if strategy != "structural":
745
+ if repo_info is None:
746
+ return _emit_error(args, "repository info unavailable")
747
+ return _run_search_engine(args, conn, snap_id, repo_info, strategy)
748
+ try:
749
+ search = StructuralSearch(conn)
750
+ filters = SearchFilters(
751
+ language=args.language, kind=args.kind, path=args.path_filter,
752
+ )
753
+ limit = clamp_limit(args.limit)
754
+ page = search.search_symbols(
755
+ repo_id, snap_id, args.query, filters, limit, max(0, args.offset)
756
+ )
757
+ results = [dict(x) for x in page.items]
758
+ payload: dict[str, Any] = {
759
+ "tool": "code-atlas", "version": __version__,
760
+ "schema_version": SCHEMA_VERSION, "command": "search",
761
+ "query": args.query, "snapshot_id": snap_id,
762
+ "repository": repo_info,
763
+ "diagnostics": [],
764
+ "filters": {"language": args.language, "kind": args.kind,
765
+ "path": args.path_filter},
766
+ "limit": limit, "offset": args.offset,
767
+ "total": page.total, "truncated": page.truncated,
768
+ "results": results,
769
+ "_human": [
770
+ f"{r['symbol_id']:>5} {r['kind']:<9} {r['language']:<10} "
771
+ f"{r['qualified_name']}" for r in results
772
+ ],
773
+ }
774
+ finally:
775
+ conn.close()
776
+ return _emit_page(args, payload)
777
+
778
+
779
+ def _run_search_engine(
780
+ args: argparse.Namespace,
781
+ conn: sqlite3.Connection,
782
+ snap_id: int,
783
+ repo_info: dict[str, Any],
784
+ strategy: str,
785
+ ) -> int:
786
+ """Estratégias do SearchEngine (lexical|graph|hybrid) com envelope v2."""
787
+ from code_atlas.search import SearchEngine, SearchRequest
788
+ from code_atlas.search.contracts import encode_cursor, query_fingerprint
789
+
790
+ offset = max(0, args.offset)
791
+ cursor = None
792
+ if offset:
793
+ fp = query_fingerprint(args.query, {
794
+ "language": args.language, "kind": args.kind,
795
+ "path": args.path_filter})
796
+ cursor = encode_cursor(offset, fp)
797
+ try:
798
+ res = SearchEngine(conn).search(
799
+ SearchRequest(
800
+ query=args.query,
801
+ strategy=strategy,
802
+ language=args.language,
803
+ kind=args.kind,
804
+ path=args.path_filter,
805
+ repository_root=str(resolve_root(args.root)),
806
+ snapshot_id=snap_id,
807
+ limit=clamp_limit(args.limit),
808
+ cursor=cursor,
809
+ )
810
+ )
811
+ finally:
812
+ conn.close()
813
+ items = [asdict(h) for h in res.items]
814
+ payload: dict[str, Any] = res.to_dict()
815
+ payload["tool"] = "code-atlas"
816
+ payload["version"] = __version__
817
+ payload["command"] = "search"
818
+ payload["repository"] = repo_info
819
+ payload["_human"] = [
820
+ f"{h['qualified_name']:<40} {h['path']} "
821
+ f"[{','.join(h['matched_fields']) or 'structural'} "
822
+ f"score={h['score']:.4f}]" for h in items
823
+ ]
824
+ return _emit_page(args, payload)
825
+
826
+
827
+ def run_context(args: argparse.Namespace) -> int:
828
+ """`code-atlas context TASK` — pack de contexto sob budget (lf-0040)."""
829
+ conn, _repo_id, snap_id, _info, err = _search_context(args)
830
+ if err:
831
+ return _emit_error(args, err)
832
+ assert conn is not None and snap_id is not None
833
+ from code_atlas.context import (
834
+ ContextEngine,
835
+ ContextRequest,
836
+ render_json,
837
+ render_markdown,
838
+ )
839
+
840
+ try:
841
+ pack = ContextEngine(conn).build(ContextRequest(
842
+ task=args.task,
843
+ repository_root=str(resolve_root(args.root)),
844
+ snapshot_id=snap_id,
845
+ level=args.level,
846
+ budget_tokens=args.budget,
847
+ max_symbols=clamp_limit(args.limit),
848
+ ))
849
+ finally:
850
+ conn.close()
851
+ if args.format == "markdown":
852
+ print(render_markdown(pack))
853
+ return EXIT_OK
854
+ if args.json or args.format == "json":
855
+ print(render_json(pack))
856
+ return EXIT_OK
857
+ # humano: linhas resumidas
858
+ m = pack.manifest
859
+ print(f"# pack snapshot={m.snapshot_id} level={m.level} "
860
+ f"selected={m.selected} omitted={m.omitted} "
861
+ f"truncated={str(m.truncated).lower()}")
862
+ for i in pack.items:
863
+ print(escape_control(
864
+ f" [{i.priority}] {i.kind:<10} {i.title} "
865
+ f"(~{i.est_tokens} tok)"))
866
+ if pack.diagnostics.codes:
867
+ print(f"diagnostics: {', '.join(pack.diagnostics.codes)}")
868
+ return EXIT_OK
869
+
870
+
871
+ def run_context_expand(args: argparse.Namespace) -> int:
872
+ """`code-atlas context-expand --symbol X` — expansão autocontida."""
873
+ conn, _repo_id, snap_id, _info, err = _search_context(args)
874
+ if err:
875
+ return _emit_error(args, err)
876
+ assert conn is not None and snap_id is not None
877
+ from code_atlas.context import ContextExpander, expansion_to_json
878
+
879
+ delivered = frozenset(
880
+ s for s in (args.delivered or "").split(",") if s)
881
+ try:
882
+ res = ContextExpander(conn).expand(
883
+ snap_id, args.symbol, delivered=delivered,
884
+ budget_tokens=args.budget)
885
+ finally:
886
+ conn.close()
887
+ if res.error:
888
+ return _emit_error(args, res.error)
889
+ if args.json:
890
+ print(expansion_to_json(res))
891
+ else:
892
+ for i in res.added:
893
+ print(escape_control(
894
+ f" + {i.symbol_key} (~{i.est_tokens} tok) {i.path}"))
895
+ for r in res.remaining_refs:
896
+ print(escape_control(f" … {r.ref_id} ({r.reason})"))
897
+ return EXIT_OK
898
+
899
+
900
+ _MCP_MANIFESTS: dict[str, dict[str, Any]] = {
901
+ "claude-code": {"mcpServers": {"code-atlas": {
902
+ "command": "code-atlas", "args": ["mcp"]}}},
903
+ "codex-cli": {"mcp_servers": {"code-atlas": {
904
+ "command": "code-atlas", "args": ["mcp"]}}},
905
+ "generic": {"mcpServers": {"code-atlas": {
906
+ "command": "code-atlas", "args": ["mcp"]}}},
907
+ }
908
+
909
+
910
+ def run_api(args: argparse.Namespace) -> int:
911
+ """`code-atlas api serve` — API HTTP local loopback (Cycle 6)."""
912
+ from code_atlas.api.server import serve
913
+
914
+ db_file = db_path()
915
+ if not db_file.exists():
916
+ print("banco não existe — rode `code-atlas index` antes",
917
+ file=sys.stderr)
918
+ return EXIT_ERROR
919
+ server = serve(str(db_file), host=args.host, port=args.port,
920
+ verbose=args.verbose)
921
+ print(f"API Explorer em {server.url} — Ctrl+C para parar")
922
+ try:
923
+ server.serve_forever()
924
+ except KeyboardInterrupt:
925
+ pass
926
+ finally:
927
+ server.shutdown()
928
+ return EXIT_OK
929
+
930
+
931
+ def run_explorer(args: argparse.Namespace) -> int:
932
+ """`code-atlas explorer` — serve API + assets compilados do Explorer.
933
+
934
+ Nunca abre o navegador sem `--open`; bind sempre loopback; se a dist
935
+ do Angular não existir (nem empacotada nem no checkout), serve só a
936
+ API e avisa.
937
+ """
938
+ from code_atlas.api.server import explorer_static_root, serve
939
+
940
+ db_file = db_path()
941
+ if not db_file.exists():
942
+ print("banco não existe — rode `code-atlas index` antes",
943
+ file=sys.stderr)
944
+ return EXIT_ERROR
945
+ static = explorer_static_root(Path(__file__).resolve().parents[2])
946
+ if static is None:
947
+ print("Explorer não compilado — servindo apenas a API "
948
+ "(build: cd explorer && npm run build)", file=sys.stderr)
949
+ try:
950
+ server = serve(str(db_file), host=args.host, port=args.port,
951
+ verbose=args.verbose, static_root=static)
952
+ except OSError as e:
953
+ print(f"porta indisponível: {escape_control(str(e))}",
954
+ file=sys.stderr)
955
+ return EXIT_ERROR
956
+ url = f"http://{server.host}:{server.port}/"
957
+ print(f"Explorer em {url} — API em {server.url}")
958
+ if args.open:
959
+ import webbrowser
960
+ webbrowser.open(url)
961
+ try:
962
+ server.serve_forever()
963
+ except KeyboardInterrupt:
964
+ pass
965
+ finally:
966
+ server.shutdown()
967
+ return EXIT_OK
968
+
969
+
970
+ def run_mcp(args: argparse.Namespace) -> int:
971
+ """`code-atlas mcp [doctor|manifest] ` — servidor stdio + host UX.
972
+
973
+ `doctor` diagnostica instalação/capabilities; `manifest` EMITE o
974
+ manifest do host (nunca edita config global — só instrui).
975
+ """
976
+ from code_atlas.mcp import main_stdio
977
+
978
+ cmd = getattr(args, "cmd", "serve") or "serve"
979
+ if cmd == "manifest":
980
+ host = getattr(args, "host", "generic") or "generic"
981
+ manifest = _MCP_MANIFESTS.get(host, _MCP_MANIFESTS["generic"])
982
+ print(json.dumps(manifest, indent=2))
983
+ print("# cole o JSON acima no config do host — Atlas nunca "
984
+ "escreve nele", file=sys.stderr)
985
+ return EXIT_OK
986
+ if cmd == "doctor":
987
+ return _mcp_doctor(args)
988
+ return main_stdio()
989
+
990
+
991
+ def _mcp_doctor(args: argparse.Namespace) -> int:
992
+ """Diagnóstico de instalação: executável, DB, capabilities, ndjson."""
993
+ import shutil
994
+
995
+ from code_atlas.search.vector import sqlite_vec_available
996
+ from code_atlas.storage import schema_version
997
+
998
+ exe = shutil.which("code-atlas") or "<não no PATH — use python -m>"
999
+ checks: dict[str, Any] = {
1000
+ "executable": exe,
1001
+ "python": sys.version.split()[0],
1002
+ "db_path": str(db_path()),
1003
+ "db_exists": db_path().exists(),
1004
+ "sqlite_vec": str(sqlite_vec_available()),
1005
+ }
1006
+ if checks["db_exists"]:
1007
+ conn = _open_db()
1008
+ checks["schema_version"] = schema_version(conn)
1009
+ conn.close()
1010
+ if args.json:
1011
+ print(json.dumps({"tool": "code-atlas",
1012
+ "command": "mcp doctor", **checks}))
1013
+ else:
1014
+ for k, v in checks.items():
1015
+ print(f"{k:<16} {escape_control(str(v))}")
1016
+ return EXIT_OK
1017
+
1018
+
1019
+ def run_symbol(args: argparse.Namespace) -> int:
1020
+ conn, repo_id, snap_id, repo_info, err = _search_context(args)
1021
+ if err:
1022
+ return _emit_error(args, err)
1023
+ assert (
1024
+ conn is not None and repo_id is not None and snap_id is not None
1025
+ )
1026
+ try:
1027
+ search = StructuralSearch(conn)
1028
+ detail = search.symbol_detail(repo_id, snap_id, args.ident)
1029
+ if detail is None:
1030
+ return _emit_error(args, f"symbol {args.ident!r} not found")
1031
+ payload: dict[str, Any] = {
1032
+ "tool": "code-atlas", "version": __version__,
1033
+ "schema_version": SCHEMA_VERSION, "command": "symbol",
1034
+ "snapshot_id": snap_id, "result": detail,
1035
+ "repository": repo_info, "diagnostics": [],
1036
+ "_human": [
1037
+ f"{detail['kind']} {detail['qualified_name']} "
1038
+ f"({detail['language']})"
1039
+ ] + [
1040
+ f" {o['path']}:{o['line']} [{o['parse_status']}]"
1041
+ for o in detail["occurrences"]
1042
+ ],
1043
+ }
1044
+ finally:
1045
+ conn.close()
1046
+ return _emit_page(args, payload)
1047
+
1048
+
1049
+ def run_references(args: argparse.Namespace) -> int:
1050
+ conn, repo_id, snap_id, repo_info, err = _search_context(args)
1051
+ if err:
1052
+ return _emit_error(args, err)
1053
+ assert (
1054
+ conn is not None and repo_id is not None and snap_id is not None
1055
+ )
1056
+ try:
1057
+ search = StructuralSearch(conn)
1058
+ page, rerr = search.references(
1059
+ repo_id, snap_id, args.ident, args.relation_type,
1060
+ clamp_limit(args.limit), max(0, args.offset),
1061
+ )
1062
+ if page is None:
1063
+ return _emit_error(args, rerr)
1064
+ results = [dict(x) for x in page.items]
1065
+ payload: dict[str, Any] = {
1066
+ "tool": "code-atlas", "version": __version__,
1067
+ "schema_version": SCHEMA_VERSION, "command": "references",
1068
+ "symbol": args.ident, "snapshot_id": snap_id,
1069
+ "repository": repo_info, "diagnostics": [],
1070
+ "limit": clamp_limit(args.limit), "offset": args.offset,
1071
+ "total": page.total, "truncated": page.truncated,
1072
+ "results": results,
1073
+ "_human": [
1074
+ f"#{r['relation_id']} {r['type']} "
1075
+ f"{r['from']['symbol']}@{r['from']['path']}:{r['from']['line']} "
1076
+ f"-> {r['to']['symbol'] if r['to'] else r['to_unresolved']} "
1077
+ f"[{r['resolution_status']}]"
1078
+ for r in results
1079
+ ],
1080
+ }
1081
+ finally:
1082
+ conn.close()
1083
+ return _emit_page(args, payload)
1084
+
1085
+
1086
+ def run_graph(args: argparse.Namespace) -> int:
1087
+ conn, repo_id, snap_id, repo_info, err = _search_context(args)
1088
+ if err:
1089
+ return _emit_error(args, err)
1090
+ assert (
1091
+ conn is not None and repo_id is not None and snap_id is not None
1092
+ )
1093
+ try:
1094
+ search = StructuralSearch(conn)
1095
+ filters = SearchFilters(
1096
+ language=args.language, kind=args.kind,
1097
+ path=args.path_filter, relation_type=args.relation_type,
1098
+ )
1099
+ dump = search.graph_dump(snap_id, filters, clamp_limit(args.limit))
1100
+ payload: dict[str, Any] = {
1101
+ "tool": "code-atlas", "version": __version__,
1102
+ "schema_version": SCHEMA_VERSION, "command": "graph",
1103
+ "snapshot_id": snap_id,
1104
+ "repository": repo_info, "diagnostics": [], "filters": {
1105
+ "language": args.language, "kind": args.kind,
1106
+ "path": args.path_filter, "relation_type": args.relation_type,
1107
+ },
1108
+ "limit": clamp_limit(args.limit),
1109
+ **dump,
1110
+ "_human": [
1111
+ f"nodes={dump['counts']['nodes']} edges={dump['counts']['edges']}",
1112
+ ],
1113
+ }
1114
+ finally:
1115
+ conn.close()
1116
+ return _emit_page(args, payload)
1117
+
1118
+
1119
+ def run_index_plan(args: argparse.Namespace) -> int:
1120
+ """``index-plan`` (lf-0022): dry-run — plano JSON, zero writes no DB."""
1121
+ if args.path and ".." in Path(args.path).parts:
1122
+ print("error: '..' traversal is not allowed in PATH", file=sys.stderr)
1123
+ return EXIT_UNSAFE
1124
+ target = (
1125
+ Path(args.path).expanduser().resolve()
1126
+ if args.path
1127
+ else resolve_root(args.root)
1128
+ )
1129
+ if not target.is_dir():
1130
+ print(f"error: {escape_control(str(target))} is not a directory",
1131
+ file=sys.stderr)
1132
+ return EXIT_UNSAFE
1133
+ conn = _open_db()
1134
+ try:
1135
+ plan, diag = IndexCoordinator(conn).plan_dry_run(target)
1136
+ finally:
1137
+ conn.close()
1138
+ assert plan is not None # dry-run sempre materializa um plano
1139
+ payload: dict[str, Any] = {
1140
+ "tool": "code-atlas", "version": __version__,
1141
+ "schema_version": SCHEMA_VERSION, "command": "index-plan",
1142
+ "root": str(target), "diagnostics": list(diag.values()),
1143
+ "plan": plan.to_dict(),
1144
+ }
1145
+ if args.json:
1146
+ print(json.dumps(payload))
1147
+ else:
1148
+ counts = plan.counts()
1149
+ print("index-plan (dry-run): " + ", ".join(
1150
+ f"{k}={v}" for k, v in sorted(counts.items())
1151
+ ))
1152
+ if diag:
1153
+ for d in diag.values():
1154
+ print(f"note: {escape_control(str(d))}")
1155
+ for f in plan.files:
1156
+ print(escape_control(
1157
+ f" {f.action:<22} {f.path} ({f.reason})"))
1158
+ return EXIT_OK
1159
+
1160
+
1161
+ def run_definitions(args: argparse.Namespace) -> int:
1162
+ """``definitions`` (lf-0022): sites de definição do símbolo."""
1163
+ conn, repo_id, snap_id, repo_info, err = _search_context(args)
1164
+ if err:
1165
+ return _emit_error(args, err)
1166
+ assert (
1167
+ conn is not None and repo_id is not None and snap_id is not None
1168
+ )
1169
+ try:
1170
+ search = StructuralSearch(conn)
1171
+ out = search.definitions(repo_id, snap_id, args.ident)
1172
+ if out is None:
1173
+ return _emit_error(args, f"symbol {args.ident!r} not found")
1174
+ defs = out["definitions"]
1175
+ payload: dict[str, Any] = {
1176
+ "tool": "code-atlas", "version": __version__,
1177
+ "schema_version": SCHEMA_VERSION, "command": "definitions",
1178
+ "query": args.ident, "snapshot_id": snap_id,
1179
+ "repository": repo_info, "diagnostics": [],
1180
+ "symbol_id": out["symbol_id"],
1181
+ "qualified_name": out["qualified_name"],
1182
+ "total": len(defs), "truncated": False,
1183
+ "results": defs,
1184
+ "_human": [
1185
+ f"{out['qualified_name']} ({out['kind']})",
1186
+ *(
1187
+ f" {d['path']}:{d['line']}:{d['col']}"
1188
+ f" [{d['parse_status']}]"
1189
+ for d in defs
1190
+ ),
1191
+ ],
1192
+ }
1193
+ finally:
1194
+ conn.close()
1195
+ return _emit_page(args, payload)
1196
+
1197
+
1198
+ def run_impact(args: argparse.Namespace) -> int:
1199
+ """``impact`` (Cycle 5): análise de impacto base e target (snapshots)."""
1200
+ from code_atlas.impact.contracts import (
1201
+ ImpactLimits,
1202
+ ImpactRequest,
1203
+ )
1204
+ from code_atlas.impact.engine import ImpactEngine
1205
+ from code_atlas.impact.report import to_json, to_markdown
1206
+
1207
+ root = resolve_root(args.root)
1208
+ conn = _open_db()
1209
+ try:
1210
+ base = args.base or ("HEAD" if args.cmd in ("worktree", "diff")
1211
+ else "")
1212
+ req = ImpactRequest(
1213
+ repository_root=str(root),
1214
+ base_revision=base,
1215
+ target_revision=(args.target if args.cmd != "worktree"
1216
+ else ""),
1217
+ symbol=args.ident if args.cmd in ("symbol", "contract")
1218
+ else None,
1219
+ known_only=args.known_only,
1220
+ limits=ImpactLimits(
1221
+ max_depth=args.depth, max_nodes=args.max_nodes,
1222
+ max_paths=args.max_paths))
1223
+ res = ImpactEngine(conn).analyze(req)
1224
+ if args.cmd == "tests":
1225
+ # relatório focado em candidatos a teste
1226
+ res = replace(
1227
+ res, symbol_changes=(), contract_changes=(),
1228
+ impacts=())
1229
+ if args.format == "json":
1230
+ print(to_json(res))
1231
+ return 0
1232
+ if args.format == "markdown":
1233
+ print(to_markdown(res))
1234
+ return 0
1235
+ # humano: resumo determinístico
1236
+ print("impact: "
1237
+ + escape_control(f"{res.base_revision} → {res.target_revision}"))
1238
+ print(f" snapshots: {res.base_snapshot_id} → "
1239
+ f"{res.target_snapshot_id}")
1240
+ print(f" changes: {len(res.symbol_changes)} símbolo(s), "
1241
+ f"{len(res.contract_changes)} contrato(s)")
1242
+ print(f" impacts: {len(res.impacts)} "
1243
+ f"(diretos: {sum(1 for i in res.impacts if i.impact.value == 'direct')})")
1244
+ print(f" tests: {len(res.test_candidates)} candidato(s)")
1245
+ print(f" risk: {res.risk.category.value} "
1246
+ f"(uncertainty={res.risk.uncertainty})")
1247
+ for r in res.risk.reasons:
1248
+ print(escape_control(f" - {r}"))
1249
+ for d in res.diagnostics:
1250
+ print(escape_control(f" diagnostic {d.code}: {d.message}"),
1251
+ file=sys.stderr)
1252
+ if res.truncated:
1253
+ # lf-0151: paridade com JSON/Markdown — as razões do
1254
+ # corte fazem parte do estado reportado.
1255
+ why = escape_control(", ".join(res.truncation_reasons))
1256
+ print(" TRUNCATED — limites atingidos"
1257
+ + (f" ({why})" if why else ""))
1258
+ return 0
1259
+ finally:
1260
+ conn.close()
1261
+
1262
+
1263
+ def _fed_cmd_create(fed: Federation, args: argparse.Namespace) -> dict[str, Any]:
1264
+ ws = fed.create_workspace(args.name)
1265
+ return {"workspace_id": ws.id, "workspace": ws.name, "created": True}
1266
+
1267
+
1268
+ def _fed_cmd_add(fed: Federation, args: argparse.Namespace) -> dict[str, Any]:
1269
+ ws = fed.get_workspace(args.name)
1270
+ repo_id = fed.resolve_repository(args.repo)
1271
+ member = fed.add_member(ws, repo_id)
1272
+ return {
1273
+ "workspace_id": ws.id, "workspace": ws.name,
1274
+ "repository_id": member.repository_id, "member_id": member.id,
1275
+ }
1276
+
1277
+
1278
+ def _fed_cmd_pin(fed: Federation, args: argparse.Namespace) -> dict[str, Any]:
1279
+ from code_atlas.federation import InvalidPinError
1280
+
1281
+ ws = fed.get_workspace(args.name)
1282
+ repo_id = fed.resolve_repository(args.repo)
1283
+ if args.snapshot == "latest":
1284
+ member = fed.unpin_member(ws, repo_id)
1285
+ else:
1286
+ try:
1287
+ snap_id = int(args.snapshot)
1288
+ except ValueError as exc:
1289
+ raise InvalidPinError(
1290
+ f"snapshot must be an id or 'latest': {args.snapshot!r}"
1291
+ ) from exc
1292
+ member = fed.pin_member(ws, repo_id, snap_id)
1293
+ return {
1294
+ "workspace_id": ws.id, "workspace": ws.name,
1295
+ "repository_id": member.repository_id,
1296
+ "pinned_snapshot_id": member.pinned_snapshot_id,
1297
+ }
1298
+
1299
+
1300
+ def _fed_cmd_view(fed: Federation, args: argparse.Namespace) -> dict[str, Any]:
1301
+ from code_atlas.federation import InvalidPinError
1302
+
1303
+ ws = fed.get_workspace(args.name)
1304
+ pins: dict[int, int] = {}
1305
+ for seg in (args.pin or "").split(","):
1306
+ seg = seg.strip()
1307
+ if not seg:
1308
+ continue
1309
+ repo, sep, snap = seg.partition(":")
1310
+ if not sep or not repo.isdigit() or not snap.isdigit():
1311
+ raise InvalidPinError(
1312
+ "pin must be repo:snapshot[,repo:snapshot…]"
1313
+ )
1314
+ pins[int(repo)] = int(snap)
1315
+ return fed.resolve_view(ws, pins).to_dict()
1316
+
1317
+
1318
+ _FED_COMMANDS = {
1319
+ "create": _fed_cmd_create,
1320
+ "add": _fed_cmd_add,
1321
+ "pin": _fed_cmd_pin,
1322
+ "view": _fed_cmd_view,
1323
+ }
1324
+
1325
+ _FED_REQUIRED = {
1326
+ "create": ("name",), "add": ("name", "repo"),
1327
+ "pin": ("name", "repo", "snapshot"), "view": ("name",),
1328
+ }
1329
+
1330
+
1331
+ def run_federation(args: argparse.Namespace) -> int:
1332
+ """``federation`` (lf-0118, Phase 10): agrupamentos multi-repo.
1333
+
1334
+ A mutação (create/add/pin) é superfície exclusiva da CLI — a API HTTP
1335
+ expõe somente views/lookups/graphs GET. Membros só entram de repos
1336
+ **já indexados**; nada de auto-discovery de diretórios.
1337
+ """
1338
+ from code_atlas.federation import Federation, FederationError
1339
+
1340
+ cmd = getattr(args, "fed_cmd", "view") or "view"
1341
+ missing = [k for k in _FED_REQUIRED.get(cmd, ())
1342
+ if not getattr(args, k, None)]
1343
+ if missing:
1344
+ print(f"error: federation {cmd} requires "
1345
+ f"{', '.join(missing)}", file=sys.stderr)
1346
+ return EXIT_USAGE
1347
+ conn = _open_db()
1348
+ try:
1349
+ try:
1350
+ payload = _FED_COMMANDS[cmd](Federation(conn), args)
1351
+ except FederationError as exc:
1352
+ return _emit_error(args, str(exc))
1353
+ finally:
1354
+ conn.close()
1355
+ payload["tool"] = "code-atlas"
1356
+ payload["version"] = __version__
1357
+ payload["command"] = f"federation {cmd}"
1358
+ if args.json:
1359
+ print(json.dumps(payload))
1360
+ elif cmd == "view":
1361
+ print(f"view {escape_control(str(payload['view_id']))}")
1362
+ for m in payload["members"]:
1363
+ print(
1364
+ f" repo={m['repository_id']} "
1365
+ f"snapshot={m['snapshot_id']} "
1366
+ f"[{m['resolution']}] {m['snapshot_status']}"
1367
+ )
1368
+ else:
1369
+ for k, v in payload.items():
1370
+ if not k.startswith("_") and k not in ("tool", "version",
1371
+ "command"):
1372
+ print(f"{k}: {escape_control(str(v))}")
1373
+ return EXIT_OK
1374
+
1375
+
1376
+ # --- domains (lf-0123, Cycle 7 Phase 15) ------------------------------------
1377
+
1378
+
1379
+ def _domain_repo(
1380
+ conn: sqlite3.Connection, args: argparse.Namespace, ident: str | None
1381
+ ) -> tuple[int | None, str]:
1382
+ """Escopo do repo: ``--repo`` > ``--root`` > derivado do ``ident``.
1383
+
1384
+ ``--repo`` aceita id | identity_key | root_handle | path (mesma
1385
+ resolução de ``federation add`` — reusa ``Federation.resolve_repository``).
1386
+ ``ident`` numérico/``dom:``/nome único deriva o repo do próprio dado
1387
+ — ``domains entity`` funciona sem flags quando a chave é exata.
1388
+ """
1389
+ repo_ref = getattr(args, "repo", None)
1390
+ root_arg = getattr(args, "root", None)
1391
+ if repo_ref:
1392
+ from code_atlas.federation import Federation, FederationError
1393
+
1394
+ try:
1395
+ return Federation(conn).resolve_repository(str(repo_ref)), ""
1396
+ except FederationError as exc:
1397
+ return None, str(exc)
1398
+ if root_arg:
1399
+ root = resolve_root(root_arg)
1400
+ repo_id = StructuralSearch(conn).repo_for_root(str(root))
1401
+ if repo_id is None:
1402
+ return None, f"no indexed repository at {root}"
1403
+ return repo_id, ""
1404
+ if ident:
1405
+ from code_atlas.domains.cli_surface import repo_for_ident
1406
+
1407
+ repo_id, derr = repo_for_ident(conn, ident)
1408
+ if derr:
1409
+ return None, derr
1410
+ if repo_id is not None:
1411
+ return repo_id, ""
1412
+ root = resolve_root(root_arg)
1413
+ repo_id = StructuralSearch(conn).repo_for_root(str(root))
1414
+ if repo_id is None:
1415
+ return None, f"no indexed repository at {root}"
1416
+ return repo_id, ""
1417
+
1418
+
1419
+ def _domain_context(
1420
+ args: argparse.Namespace, ident: str | None = None
1421
+ ) -> tuple[
1422
+ sqlite3.Connection | None,
1423
+ int | None,
1424
+ int | None,
1425
+ dict[str, Any] | None,
1426
+ str,
1427
+ ]:
1428
+ """(conn, repo_id, snapshot_id, repo_info, err) — mesmo contrato de
1429
+ ``_search_context``, mas o repo pode vir de ``--repo``/``ident``."""
1430
+ conn = _open_db()
1431
+ repo_id, err = _domain_repo(conn, args, ident)
1432
+ if err or repo_id is None:
1433
+ conn.close()
1434
+ return None, None, None, None, err or "repository unresolved"
1435
+ search = StructuralSearch(conn)
1436
+ snap, serr = search.resolve_snapshot(
1437
+ repo_id, getattr(args, "snapshot", None))
1438
+ if serr or snap is None or snap.id is None:
1439
+ conn.close()
1440
+ return None, None, None, None, serr or "snapshot missing id"
1441
+ repo_info = search.repository_info(repo_id) or {"id": repo_id}
1442
+ return conn, repo_id, int(snap.id), repo_info, ""
1443
+
1444
+
1445
+ def _dom_cmd_list(
1446
+ args: argparse.Namespace,
1447
+ ) -> tuple[dict[str, Any] | None, str]:
1448
+ """``domains list`` — adapters registrados (não extrai nada)."""
1449
+ from code_atlas.domains.cli_surface import adapters_report
1450
+
1451
+ adapters = adapters_report()
1452
+ width = max((len(a["domain_id"]) for a in adapters), default=0)
1453
+ return {
1454
+ "command": "domains list",
1455
+ "adapters": adapters,
1456
+ "notes": [
1457
+ "registered adapters are opt-in: extraction only runs when "
1458
+ "`code-atlas index --domains` selects them",
1459
+ ],
1460
+ "_human": [
1461
+ "domain".ljust(width) + " version enabled artifacts",
1462
+ *(
1463
+ f"{a['domain_id']:<{width}} {a['adapter_version']:<8} "
1464
+ f"{'yes' if a['enabled'] else 'no':<7} "
1465
+ + ",".join(a["artifact_support"]["extensions"] or ["-"])
1466
+ + (f"; globs={a['artifact_support']['globs']}"
1467
+ if a["artifact_support"]["globs"] else "")
1468
+ for a in adapters
1469
+ ),
1470
+ "note: opt-in — `index --domains <ids|all>` enables extraction",
1471
+ ],
1472
+ }, ""
1473
+
1474
+
1475
+ def _dom_cmd_entities(
1476
+ args: argparse.Namespace,
1477
+ ) -> tuple[dict[str, Any] | None, str]:
1478
+ """``domains entities`` — listagem determinística do snapshot."""
1479
+ from code_atlas.domains.cli_surface import (
1480
+ NOTE_OPT_IN,
1481
+ list_entities,
1482
+ )
1483
+ from code_atlas.domains.model import DomainEntityKind
1484
+
1485
+ kind = (getattr(args, "kind", None) or "").strip()
1486
+ if kind and kind not in {str(k) for k in DomainEntityKind}:
1487
+ return None, (
1488
+ f"unknown --kind {kind!r} (valid: "
1489
+ f"{', '.join(sorted(str(k) for k in DomainEntityKind))})"
1490
+ )
1491
+ conn, repo_id, snap_id, repo_info, err = _domain_context(args)
1492
+ if err:
1493
+ return None, err
1494
+ assert conn is not None and repo_id is not None and snap_id is not None
1495
+ try:
1496
+ limit = clamp_limit(args.limit)
1497
+ offset = max(0, getattr(args, "offset", 0) or 0)
1498
+ domain = (getattr(args, "domain", None) or "").strip()
1499
+ q = (getattr(args, "q", None) or "").strip()
1500
+ out = list_entities(
1501
+ conn, repo_id, snap_id,
1502
+ domain=domain, kind=kind, q=q, limit=limit, offset=offset)
1503
+ finally:
1504
+ conn.close()
1505
+ notes = [NOTE_OPT_IN] if out["total"] == 0 else []
1506
+ items = out["items"]
1507
+ return {
1508
+ "command": "domains entities",
1509
+ "repository": repo_info,
1510
+ "snapshot_id": snap_id,
1511
+ "filters": {"domain": domain, "kind": kind, "q": q},
1512
+ "limit": limit,
1513
+ "offset": offset,
1514
+ "total": out["total"],
1515
+ "truncated": out["truncated"],
1516
+ "items": items,
1517
+ "notes": notes,
1518
+ "_human": [
1519
+ *(
1520
+ f"{i['entity_id']:>4} {i['domain']:<10} {i['kind']:<20} "
1521
+ f"{i['display_name']} x{i['occurrences']}"
1522
+ f"{' ' + i['path'] if i['path'] else ''}"
1523
+ for i in items
1524
+ ),
1525
+ *notes,
1526
+ ],
1527
+ }, ""
1528
+
1529
+
1530
+ def _dom_cmd_entity(
1531
+ args: argparse.Namespace,
1532
+ ) -> tuple[dict[str, Any] | None, str]:
1533
+ """``domains entity <key>`` — cartão de identidade + proveniência."""
1534
+ from code_atlas.domains.cli_surface import (
1535
+ entity_detail,
1536
+ resolve_single_entity,
1537
+ )
1538
+
1539
+ ident = str(args.ident)
1540
+ conn, repo_id, snap_id, repo_info, err = _domain_context(args, ident)
1541
+ if err:
1542
+ return None, err
1543
+ assert conn is not None and repo_id is not None and snap_id is not None
1544
+ try:
1545
+ entity, eerr = resolve_single_entity(conn, repo_id, ident)
1546
+ if entity is None:
1547
+ return None, eerr
1548
+ detail = entity_detail(conn, entity, snap_id)
1549
+ finally:
1550
+ conn.close()
1551
+ identd = detail["identity"]
1552
+ notes: list[str] = []
1553
+ if not detail["occurrences"]:
1554
+ notes.append(
1555
+ f"no occurrences in snapshot {snap_id} — entity was "
1556
+ "extracted under another snapshot or a different "
1557
+ "enablement set"
1558
+ )
1559
+ human = [
1560
+ f"entity #{detail['entity_id']} {identd['display_name']} "
1561
+ f"({identd['kind']}) — domain={identd['domain']}",
1562
+ f"identity_key: {identd['identity_key']}",
1563
+ f" catalog={identd['catalog'] or '-'} "
1564
+ f"namespace={identd['namespace'] or '-'} "
1565
+ f"environment={identd['environment'] or '-'} "
1566
+ f"name={identd['name']}",
1567
+ f"occurrences (snapshot {snap_id}): "
1568
+ f"{len(detail['occurrences'])}",
1569
+ *(
1570
+ f" {o['path'] or '<synthesized>'}:{o['line'] or '-'}:"
1571
+ f"{o['col'] or '-'} [{o['extraction_status']}]"
1572
+ for o in detail["occurrences"]
1573
+ ),
1574
+ f"relations: {sum(r['count'] for r in detail['relations_summary'])}",
1575
+ *(
1576
+ f" {r['direction']:<4} {r['type']:<20} {r['status']:<11} "
1577
+ f"x{r['count']}"
1578
+ for r in detail["relations_summary"]
1579
+ ),
1580
+ f"evidence: {detail['evidence_count']} distinct row(s)",
1581
+ *notes,
1582
+ ]
1583
+ return {
1584
+ "command": "domains entity",
1585
+ "repository": repo_info,
1586
+ "snapshot_id": snap_id,
1587
+ "entity": detail,
1588
+ "notes": notes,
1589
+ "_human": human,
1590
+ }, ""
1591
+
1592
+
1593
+ def _dom_cmd_lineage(
1594
+ args: argparse.Namespace,
1595
+ ) -> tuple[dict[str, Any] | None, str]:
1596
+ """``domains lineage <table>`` — traversal de fluxo bounded."""
1597
+ from code_atlas.domains.cli_surface import (
1598
+ DATA_FLOW_TYPES,
1599
+ lineage,
1600
+ resolve_single_entity,
1601
+ )
1602
+
1603
+ ident = str(args.ident)
1604
+ conn, repo_id, snap_id, repo_info, err = _domain_context(args, ident)
1605
+ if err:
1606
+ return None, err
1607
+ assert conn is not None and repo_id is not None and snap_id is not None
1608
+ try:
1609
+ entity, eerr = resolve_single_entity(conn, repo_id, ident)
1610
+ if entity is None:
1611
+ return None, eerr
1612
+ out = lineage(
1613
+ conn, entity, snap_id,
1614
+ direction=getattr(args, "direction", "both"),
1615
+ depth=clamp_depth(getattr(args, "depth", 2)),
1616
+ limit=clamp_limit(getattr(args, "limit", None)))
1617
+ finally:
1618
+ conn.close()
1619
+ types = ", ".join(sorted(DATA_FLOW_TYPES))
1620
+ notes = (
1621
+ [f"no data-flow relations ({types}) touch this entity in "
1622
+ f"snapshot {snap_id}"]
1623
+ if not out["edges"] else []
1624
+ )
1625
+ ent = out["entity"]
1626
+ human = [
1627
+ f"lineage of {ent['display_name']} ({ent['kind']}) — "
1628
+ f"direction={out['direction']} depth<={out['depth']} "
1629
+ f"limit={out['limit']} truncated={out['truncated']}",
1630
+ ]
1631
+ if out["direction"] in ("up", "both"):
1632
+ human.append(f"upstream (feeds {ent['display_name']}):")
1633
+ human += [
1634
+ f" d{e['depth']} {e['from']} → {e['to']} "
1635
+ f"[{e['type']} {e['resolution_status']}]"
1636
+ for e in out["edges"] if e["via"] == "up"
1637
+ ]
1638
+ if out["direction"] in ("down", "both"):
1639
+ human.append(f"downstream (fed by {ent['display_name']}):")
1640
+ human += [
1641
+ f" d{e['depth']} {e['from']} → {e['to']} "
1642
+ f"[{e['type']} {e['resolution_status']}]"
1643
+ for e in out["edges"] if e["via"] == "down"
1644
+ ]
1645
+ human += notes
1646
+ payload = {
1647
+ "command": "domains lineage",
1648
+ "repository": repo_info,
1649
+ "snapshot_id": snap_id,
1650
+ "notes": notes,
1651
+ "_human": human,
1652
+ }
1653
+ payload.update(out)
1654
+ return payload, ""
1655
+
1656
+
1657
+ _DOM_COMMANDS = {
1658
+ "list": _dom_cmd_list,
1659
+ "entities": _dom_cmd_entities,
1660
+ "entity": _dom_cmd_entity,
1661
+ "lineage": _dom_cmd_lineage,
1662
+ }
1663
+
1664
+ _DOM_REQUIRED = {
1665
+ "entity": ("ident",),
1666
+ "lineage": ("ident",),
1667
+ }
1668
+
1669
+
1670
+ def run_domains(args: argparse.Namespace) -> int:
1671
+ """``domains`` (lf-0123, Phase 15): superfície read-only de domínio.
1672
+
1673
+ Todos os subcomandos são somente leitura e ancorados no snapshot
1674
+ resolvido — adapters desabilitados produzem resposta vazia com
1675
+ nota, nunca erro.
1676
+ """
1677
+ cmd = getattr(args, "dom_cmd", "list") or "list"
1678
+ missing = [k for k in _DOM_REQUIRED.get(cmd, ())
1679
+ if not getattr(args, k, None)]
1680
+ if missing:
1681
+ print(f"error: domains {cmd} requires "
1682
+ f"{', '.join(missing)}", file=sys.stderr)
1683
+ return EXIT_USAGE
1684
+ try:
1685
+ payload, err = _DOM_COMMANDS[cmd](args)
1686
+ except KeyError:
1687
+ print(f"error: unknown domains command {cmd!r}", file=sys.stderr)
1688
+ return EXIT_USAGE
1689
+ if err or payload is None:
1690
+ return _emit_error(args, err or "domains command failed")
1691
+ payload["tool"] = "code-atlas"
1692
+ payload["version"] = __version__
1693
+ payload["schema_version"] = SCHEMA_VERSION
1694
+ return _emit_page(args, payload)
1695
+
1696
+
1697
+ def run_dependencies(args: argparse.Namespace) -> int:
1698
+ """``dependencies`` (lf-0022): arestas saíntes, BFS limitado."""
1699
+ conn, repo_id, snap_id, repo_info, err = _search_context(args)
1700
+ if err:
1701
+ return _emit_error(args, err)
1702
+ assert (
1703
+ conn is not None and repo_id is not None and snap_id is not None
1704
+ )
1705
+ depth = clamp_depth(args.depth)
1706
+ try:
1707
+ search = StructuralSearch(conn)
1708
+ out, derr = search.dependencies(
1709
+ repo_id, snap_id, args.ident, depth,
1710
+ clamp_limit(args.limit), max(0, args.offset),
1711
+ )
1712
+ if out is None:
1713
+ return _emit_error(args, derr)
1714
+ results = out["results"]
1715
+ payload: dict[str, Any] = {
1716
+ "tool": "code-atlas", "version": __version__,
1717
+ "schema_version": SCHEMA_VERSION, "command": "dependencies",
1718
+ "query": args.ident, "snapshot_id": snap_id,
1719
+ "repository": repo_info, "diagnostics": [],
1720
+ "symbol_id": out["symbol_id"],
1721
+ "qualified_name": out["qualified_name"],
1722
+ "depth": depth, "limit": clamp_limit(args.limit),
1723
+ "offset": max(0, args.offset),
1724
+ "total": out["total"], "truncated": out["truncated"],
1725
+ "visited_symbols": out["visited_symbols"],
1726
+ "results": results,
1727
+ "_human": [
1728
+ f"deps of {out['qualified_name']} "
1729
+ f"(depth={depth}, visited={out['visited_symbols']})",
1730
+ *(
1731
+ f" d{r['depth']} {r['type']} -> "
1732
+ f"{r['to']['symbol'] if r['to'] else r['to_unresolved']}"
1733
+ f" [{r['resolution_status']}]"
1734
+ for r in results
1735
+ ),
1736
+ ],
1737
+ }
1738
+ finally:
1739
+ conn.close()
1740
+ return _emit_page(args, payload)
1741
+
1742
+
1743
+ def clamp_depth(depth: int | None) -> int:
1744
+ """Sane default + teto duro de traversal (lf-0022)."""
1745
+ if depth is None:
1746
+ return 1
1747
+ return max(1, min(int(depth), MAX_DEPTH))
1748
+
1749
+
1750
+ def build_parser() -> argparse.ArgumentParser:
1751
+ parser = argparse.ArgumentParser(
1752
+ prog="code-atlas",
1753
+ description="Code Atlas — inteligência estrutural de código local-first (Cycle 1).",
1754
+ epilog="exit codes: 0 ok | 1 error | 2 usage | 3 unsafe/blocked",
1755
+ )
1756
+ parser.add_argument("--version", action="version", version=f"code-atlas {__version__}")
1757
+ sub = parser.add_subparsers(dest="command", required=True)
1758
+
1759
+ p_doctor = sub.add_parser("doctor", help="verifica ambiente, config e dependências")
1760
+ p_doctor.add_argument("--json", action="store_true", help="saída em JSON")
1761
+ p_doctor.set_defaults(func=run_doctor)
1762
+
1763
+ p_init = sub.add_parser("init", help="inicializa config, data dir e registro do repo")
1764
+ p_init.add_argument("--root", metavar="PATH", help="raiz do workspace (default: cwd)")
1765
+ p_init.add_argument("--force", action="store_true", help="sobrescreve config existente")
1766
+ p_init.add_argument("--json", action="store_true", help="saída em JSON")
1767
+ p_init.set_defaults(func=run_init)
1768
+
1769
+ p_inst = sub.add_parser(
1770
+ "install",
1771
+ help="provisiona ambiente local (data dir, config, registro) + receipt")
1772
+ p_inst.add_argument(
1773
+ "action", nargs="?", choices=("install", "status", "doctor"),
1774
+ default="install",
1775
+ help="install=plano/aplica; status=recibo gravado; doctor=saúde")
1776
+ p_inst.add_argument("--scope", choices=("project", "user"),
1777
+ default="project",
1778
+ help="project=workspace atual; user=só data dir global")
1779
+ p_inst.add_argument("--root", metavar="PATH",
1780
+ help="raiz do workspace (default: cwd)")
1781
+ p_inst.add_argument("--profile",
1782
+ choices=("minimal", "recommended", "full"),
1783
+ default="recommended")
1784
+ p_inst.add_argument("--components", metavar="CSV", default=None,
1785
+ help="csv de componentes: config,data-dir,repository")
1786
+ p_inst.add_argument("--dry-run", action="store_true",
1787
+ help="mostra o plano; não escreve nada")
1788
+ p_inst.add_argument("--yes", "-y", action="store_true",
1789
+ help="aprovação explícita para aplicar")
1790
+ p_inst.add_argument("--json", action="store_true", help="saída em JSON")
1791
+ from code_atlas.install.cli import run_install
1792
+
1793
+ p_inst.set_defaults(func=run_install)
1794
+
1795
+ p_index = sub.add_parser("index", help="indexa inventário de um diretório (snapshot)")
1796
+ p_index.add_argument("path", nargs="?", metavar="PATH", help="diretório a indexar")
1797
+ p_index.add_argument("--root", metavar="PATH", help="raiz alternativa (se PATH ausente)")
1798
+ p_index.add_argument(
1799
+ "--full", action="store_true", help="força re-scan mesmo sem mudanças"
1800
+ )
1801
+ p_index.add_argument(
1802
+ "--incremental", action="store_true", default=True,
1803
+ help="indexação incremental (default ativo; --full desativa)",
1804
+ )
1805
+ p_index.add_argument(
1806
+ "--scip", metavar="PATH", default=None,
1807
+ help="índice SCIP ad-hoc (sobrescreve [resolvers.scip].index_path)",
1808
+ )
1809
+ p_index.add_argument(
1810
+ "--resolver", metavar="NAME", action="append", default=None,
1811
+ help="restringe resolvers ativos (syntactic|scip|lsp|lsp:<nome>); "
1812
+ "repetível ou separado por vírgula",
1813
+ )
1814
+ p_index.add_argument(
1815
+ "--domains", metavar="LIST", default=None,
1816
+ help="adapters de domínio opt-in: ids separados por vírgula "
1817
+ "(sql,pyspark,aws-data,terraform,cicd,api) ou 'all' — "
1818
+ "default OFF (ver `code-atlas domains list`)",
1819
+ )
1820
+ p_index.add_argument(
1821
+ "--cross-domain", action="store_true",
1822
+ help="resolução cross-domain no snapshot (requer --domains)",
1823
+ )
1824
+ p_index.add_argument(
1825
+ "--verbose", action="store_true",
1826
+ help="progresso por estágio em stderr: ms por etapa + files/s "
1827
+ "no parse (stdout e --json não são tocados)",
1828
+ )
1829
+ p_index.add_argument("--json", action="store_true", help="saída em JSON")
1830
+ p_index.set_defaults(func=run_index)
1831
+
1832
+ p_sem = sub.add_parser(
1833
+ "semantic-status",
1834
+ help="resolvers instalados/habilitados/usados + cobertura semântica",
1835
+ )
1836
+ p_sem.add_argument("path", nargs="?", metavar="PATH",
1837
+ help="diretório do workspace")
1838
+ p_sem.add_argument("--root", metavar="PATH",
1839
+ help="raiz alternativa (se PATH ausente)")
1840
+ p_sem.add_argument("--json", action="store_true", help="saída em JSON")
1841
+ p_sem.set_defaults(func=run_semantic_status)
1842
+
1843
+ p_plan = sub.add_parser(
1844
+ "index-plan", help="dry-run: plano de indexação em JSON (zero writes)"
1845
+ )
1846
+ p_plan.add_argument("path", nargs="?", metavar="PATH",
1847
+ help="diretório a planejar")
1848
+ p_plan.add_argument("--root", metavar="PATH",
1849
+ help="raiz alternativa (se PATH ausente)")
1850
+ p_plan.add_argument("--json", action="store_true", help="saída em JSON")
1851
+ p_plan.set_defaults(func=run_index_plan)
1852
+
1853
+ p_status = sub.add_parser("status", help="estado do índice (git, snapshot, migrações)")
1854
+ p_status.add_argument("--root", metavar="PATH", help="raiz do workspace (default: cwd)")
1855
+ p_status.add_argument("--json", action="store_true", help="saída em JSON")
1856
+ p_status.set_defaults(func=run_status)
1857
+
1858
+ p_search = sub.add_parser(
1859
+ "search", help="busca estrutural por símbolo (substring)"
1860
+ )
1861
+ p_search.add_argument("query", metavar="QUERY", help="substring do qualified_name")
1862
+ p_search.add_argument(
1863
+ "--strategy", metavar="NAME", default="structural",
1864
+ help="structural|lexical|graph|hybrid (default structural)")
1865
+ _add_query_args(p_search, p_path=True)
1866
+ p_search.set_defaults(func=run_search)
1867
+
1868
+ p_ctx = sub.add_parser(
1869
+ "context", help="context pack sob budget de tokens (lf-0040)")
1870
+ p_ctx.add_argument("task", metavar="TASK", help="descrição da tarefa")
1871
+ p_ctx.add_argument("--level", choices=("orientation", "focus", "deep"),
1872
+ default="focus", help="nível de detalhe")
1873
+ p_ctx.add_argument("--budget", metavar="TOKENS", type=int, default=4000,
1874
+ help="orçamento estimado (default 4000)")
1875
+ p_ctx.add_argument("--format", choices=("json", "markdown", "human"),
1876
+ default="human", help="renderer de saída")
1877
+ _add_query_args(p_ctx, filters=False)
1878
+ p_ctx.set_defaults(func=run_context)
1879
+
1880
+ p_exp = sub.add_parser(
1881
+ "context-expand",
1882
+ help="expande símbolo no MESMO snapshot do pack (lf-0041)")
1883
+ p_exp.add_argument("--symbol", metavar="SYMBOL", required=True,
1884
+ help="qualified_name (ou sufixo) a expandir")
1885
+ p_exp.add_argument("--delivered", metavar="IDS", default="",
1886
+ help="item_ids já entregues (csv) — dedup")
1887
+ p_exp.add_argument("--budget", metavar="TOKENS", type=int,
1888
+ default=4000)
1889
+ _add_query_args(p_exp, filters=False)
1890
+ p_exp.set_defaults(func=run_context_expand)
1891
+
1892
+ p_mcp = sub.add_parser(
1893
+ "mcp", help="servidor MCP stdio — JSON-RPC ndjson (lf-0042)")
1894
+ p_mcp.add_argument("cmd", nargs="?", default="serve",
1895
+ choices=("serve", "doctor", "manifest"))
1896
+ p_mcp.add_argument("--host", default="generic",
1897
+ choices=("claude-code", "codex-cli", "generic"))
1898
+ p_mcp.add_argument("--json", action="store_true")
1899
+ p_mcp.set_defaults(func=run_mcp)
1900
+
1901
+ p_symbol = sub.add_parser("symbol", help="detalhe de um símbolo + ocorrências")
1902
+ p_symbol.add_argument("ident", metavar="SYMBOL", help="id numérico ou qualified_name")
1903
+ _add_query_args(p_symbol, filters=False)
1904
+ p_symbol.set_defaults(func=run_symbol)
1905
+
1906
+ p_refs = sub.add_parser("references", help="relações que tocam um símbolo")
1907
+ p_refs.add_argument("ident", metavar="SYMBOL", help="id numérico ou qualified_name")
1908
+ p_refs.add_argument("--relation-type", metavar="TYPE", help="filtra por EdgeKind")
1909
+ _add_query_args(p_refs, filters=False)
1910
+ p_refs.set_defaults(func=run_references)
1911
+
1912
+ p_graph = sub.add_parser("graph", help="dump de nós+arestas do snapshot (JSON)")
1913
+ p_graph.add_argument("--relation-type", metavar="TYPE", help="filtra arestas")
1914
+ _add_query_args(p_graph, p_path=True)
1915
+ p_graph.set_defaults(func=run_graph)
1916
+
1917
+ p_defs = sub.add_parser(
1918
+ "definitions", help="sites de definição de um símbolo (path:span)"
1919
+ )
1920
+ p_defs.add_argument("ident", metavar="SYMBOL",
1921
+ help="id numérico ou qualified_name")
1922
+ _add_query_args(p_defs, filters=False)
1923
+ p_defs.set_defaults(func=run_definitions)
1924
+
1925
+ p_deps = sub.add_parser(
1926
+ "dependencies", help="arestas saíntes de um símbolo (BFS limitado)"
1927
+ )
1928
+ p_deps.add_argument("ident", metavar="SYMBOL",
1929
+ help="id numérico ou qualified_name")
1930
+ p_deps.add_argument(
1931
+ "--depth", metavar="N", type=int, default=1,
1932
+ help=f"profundidade de traversal (default 1, teto {MAX_DEPTH})",
1933
+ )
1934
+ _add_query_args(p_deps, filters=False)
1935
+ p_deps.set_defaults(func=run_dependencies)
1936
+
1937
+ p_impact = sub.add_parser(
1938
+ "impact", help="análise de impacto entre revisões (Cycle 5)")
1939
+ p_impact.add_argument(
1940
+ "cmd", nargs="?", default="report",
1941
+ choices=("diff", "worktree", "symbol", "tests", "contract",
1942
+ "report"))
1943
+ p_impact.add_argument("ident", nargs="?", metavar="SYMBOL",
1944
+ help="qualified_name para symbol/contract")
1945
+ p_impact.add_argument("--base", metavar="REV", default="",
1946
+ help="revisão base (HEAD~1, sha, branch)")
1947
+ p_impact.add_argument("--target", metavar="REV", default="",
1948
+ help="revisão alvo (vazio = worktree)")
1949
+ p_impact.add_argument("--depth", metavar="N", type=int, default=4)
1950
+ p_impact.add_argument("--max-nodes", metavar="N", type=int,
1951
+ default=500)
1952
+ p_impact.add_argument("--max-paths", metavar="N", type=int,
1953
+ default=50)
1954
+ p_impact.add_argument("--known-only", action="store_true")
1955
+ p_impact.add_argument("--format",
1956
+ choices=("json", "markdown", "human"),
1957
+ default="human")
1958
+ _add_query_args(p_impact, filters=False)
1959
+ p_impact.set_defaults(func=run_impact)
1960
+
1961
+ p_api = sub.add_parser(
1962
+ "api", help="API HTTP local para o Explorer (loopback, read-only)")
1963
+ p_api.add_argument("--host", metavar="HOST", default="127.0.0.1",
1964
+ help="bind (default 127.0.0.1 — público proibido)")
1965
+ p_api.add_argument("--port", metavar="N", type=int, default=8741)
1966
+ p_api.add_argument("--verbose", action="store_true")
1967
+ p_api.set_defaults(func=run_api)
1968
+
1969
+ p_expl = sub.add_parser(
1970
+ "explorer",
1971
+ help="inicia o Explorer visual (API + assets compilados)")
1972
+ p_expl.add_argument("--host", metavar="HOST", default="127.0.0.1")
1973
+ p_expl.add_argument("--port", metavar="N", type=int, default=8741)
1974
+ p_expl.add_argument("--open", action="store_true",
1975
+ help="abre o navegador (somente quando pedido)")
1976
+ p_expl.add_argument("--verbose", action="store_true")
1977
+ p_expl.set_defaults(func=run_explorer)
1978
+
1979
+ p_fed = sub.add_parser(
1980
+ "federation",
1981
+ help="agrupa repos indexados numa view federada "
1982
+ "(lf-0118; mutação via CLI)")
1983
+ p_fed.add_argument(
1984
+ "fed_cmd", metavar="CMD",
1985
+ choices=("create", "add", "pin", "view"),
1986
+ help="create <name> | add <ws> <repo> | pin <ws> <repo> <snap|latest>"
1987
+ " | view <ws>")
1988
+ p_fed.add_argument("name", nargs="?", metavar="NAME",
1989
+ help="nome a criar (create) ou ref <ws> (demais)")
1990
+ p_fed.add_argument("repo", nargs="?", metavar="REPO",
1991
+ help="repo id | identity_key | root_handle | path "
1992
+ "(add/pin)")
1993
+ p_fed.add_argument("snapshot", nargs="?", metavar="SNAPSHOT",
1994
+ help="snapshot id a pinar, ou 'latest' para desfazer "
1995
+ "o pin (pin)")
1996
+ p_fed.add_argument("--pin", metavar="REPO:SNAP,…", default="",
1997
+ help="overrides de pin na view (view)")
1998
+ p_fed.add_argument("--json", action="store_true", help="saída em JSON")
1999
+ p_fed.set_defaults(func=run_federation)
2000
+
2001
+ p_dom = sub.add_parser(
2002
+ "domains",
2003
+ help="inteligência de domínio read-only (lf-0123): adapters, "
2004
+ "entidades e linhagem — dados existem só quando o índice "
2005
+ "rodou com `index --domains`")
2006
+ p_dom.add_argument(
2007
+ "dom_cmd", metavar="CMD",
2008
+ choices=("list", "entities", "entity", "lineage"),
2009
+ help="list | entities | entity <key> | lineage <table>")
2010
+ p_dom.add_argument(
2011
+ "ident", nargs="?", metavar="KEY",
2012
+ help="identity_key 'dom:…' | id numérico | qualified_name | "
2013
+ "namespace.qualified_name (entity/lineage)")
2014
+ p_dom.add_argument(
2015
+ "--repo", metavar="REF",
2016
+ help="repo id | identity_key | root_handle | path "
2017
+ "(como `federation add`)")
2018
+ p_dom.add_argument("--domain", metavar="D",
2019
+ help="filtra por domain_id (entities)")
2020
+ p_dom.add_argument("--kind", metavar="K",
2021
+ help="filtra por DomainEntityKind (entities)")
2022
+ p_dom.add_argument("--q", metavar="SUBSTR",
2023
+ help="substring de qualified_name/name (entities)")
2024
+ p_dom.add_argument(
2025
+ "--direction", choices=("up", "down", "both"), default="both",
2026
+ help="up=produtores, down=consumidores (lineage; default both)")
2027
+ p_dom.add_argument(
2028
+ "--depth", metavar="N", type=int, default=2,
2029
+ help=f"hops do traversal (lineage; default 2, teto {MAX_DEPTH})")
2030
+ p_dom.add_argument(
2031
+ "--limit", metavar="N", type=int,
2032
+ help=f"máx. de resultados (default {DEFAULT_LIMIT}, teto {MAX_LIMIT})")
2033
+ p_dom.add_argument("--offset", metavar="N", type=int, default=0,
2034
+ help="deslocamento de paginação (entities)")
2035
+ p_dom.add_argument("--snapshot", metavar="ID", type=int,
2036
+ help="snapshot alvo (default: READY mais recente)")
2037
+ p_dom.add_argument("--root", metavar="PATH",
2038
+ help="raiz do workspace (default: cwd)")
2039
+ p_dom.add_argument("--json", action="store_true", help="saída em JSON")
2040
+ p_dom.set_defaults(func=run_domains)
2041
+
2042
+ return parser
2043
+
2044
+
2045
+ def _add_query_args(
2046
+ p: argparse.ArgumentParser, *, filters: bool = True, p_path: bool = False
2047
+ ) -> None:
2048
+ """Args comuns dos comandos read-only de consulta."""
2049
+ p.add_argument("--root", metavar="PATH", help="raiz do workspace (default: cwd)")
2050
+ p.add_argument("--snapshot", metavar="ID", type=int,
2051
+ help="snapshot alvo (default: READY mais recente)")
2052
+ p.add_argument("--limit", metavar="N", type=int,
2053
+ help=f"máx. de resultados (default {DEFAULT_LIMIT}, teto {MAX_LIMIT})")
2054
+ p.add_argument("--offset", metavar="N", type=int, default=0,
2055
+ help="deslocamento de paginação")
2056
+ if filters:
2057
+ p.add_argument("--language", metavar="LANG", help="filtra por linguagem")
2058
+ p.add_argument("--kind", metavar="KIND", help="filtra por NodeKind")
2059
+ if p_path:
2060
+ p.add_argument("--path", dest="path_filter", metavar="SUBSTR",
2061
+ help="substring de relative_path")
2062
+ else:
2063
+ p.set_defaults(language=None, kind=None, path_filter=None)
2064
+ p.add_argument("--json", action="store_true", help="saída em JSON")
2065
+
2066
+
2067
+ def main(argv: Sequence[str] | None = None) -> int:
2068
+ argv = list(sys.argv[1:] if argv is None else argv)
2069
+ # Bare `code-atlas install` on a TTY → guided wizard (same contract as
2070
+ # `sparkforge-aws install` / `productforge install`); flags and pipes
2071
+ # keep the argparse path unchanged.
2072
+ if argv == ["install"] and sys.stdin.isatty() and sys.stdout.isatty():
2073
+ try:
2074
+ from code_atlas.install.cli import run_guided_install
2075
+ from code_atlas.ui.kit import NonInteractive
2076
+ except ImportError:
2077
+ pass
2078
+ else:
2079
+ try:
2080
+ return run_guided_install()
2081
+ except NonInteractive:
2082
+ pass
2083
+ args = build_parser().parse_args(argv)
2084
+ return int(args.func(args))