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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- 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
|
+
]
|