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,676 @@
|
|
|
1
|
+
"""Engine de federação multi-repo (lf-0118, Cycle 7 Phase 10).
|
|
2
|
+
|
|
3
|
+
Superfície de **leitura** sobre repositórios já indexados — nenhuma
|
|
4
|
+
escrita em ``domain_*``/``snapshots``/``relations``, nenhuma execução,
|
|
5
|
+
nenhuma descoberta de diretórios fora dos repos já registrados. As
|
|
6
|
+
únicas escritas do pacote são as tabelas v10 (workspaces/membros) e só
|
|
7
|
+
são alcançadas pela CLI — a API serve conexão ``read_only``.
|
|
8
|
+
|
|
9
|
+
Regras inegociáveis:
|
|
10
|
+
|
|
11
|
+
- **isolamento por snapshot**: cada membro resolve seu próprio
|
|
12
|
+
``snapshot_id``; todas as leituras de domínio são ancoradas por
|
|
13
|
+
``deo.snapshot_id = <snapshot do membro>`` — trocar o snapshot de um
|
|
14
|
+
membro nunca toca os dados de outro;
|
|
15
|
+
- **identidade compartilhada honesta**: o join cross-repo é a
|
|
16
|
+
``identity_key`` lógica (``DomainIdentity.logical_key``) **menos** o
|
|
17
|
+
segmento ``repository`` — ``shared_key``. Mesmo nome em catálogo/
|
|
18
|
+
namespace/env/root distintos NÃO casa (é o motivo da identidade
|
|
19
|
+
qualificada existir);
|
|
20
|
+
- **arestas federadas são computadas**: ``shared_entity`` liga
|
|
21
|
+
entidades de repos distintos com a mesma ``shared_key`` — derivadas
|
|
22
|
+
da view, nunca persistidas; ``computed=True`` + ``resolver=
|
|
23
|
+
federation-view`` declaram a proveniência;
|
|
24
|
+
- **view honesta / cache key completa** (lf-0134): ``view_id`` é o
|
|
25
|
+
hash do contrato inteiro de identidade — pares
|
|
26
|
+
``(repo_id, identity_key, snapshot_id|None, digest de
|
|
27
|
+
analyzer_versions)`` + ``FEDERATION_RESOLVER_ID``/``VIEW_ID_FORMAT``
|
|
28
|
+
+ ``schema_version``. Pin, override, READY novo, mudança de
|
|
29
|
+
membership, re-bind de identidade, upgrade de adapter/resolver ou
|
|
30
|
+
migração de schema mudam a identidade da view — qualquer cache
|
|
31
|
+
chaveado em ``view_id`` invalida sem servir rows de outra identidade.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import sqlite3
|
|
39
|
+
from collections.abc import Mapping
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from code_atlas.contracts.enums import SnapshotStatus
|
|
43
|
+
from code_atlas.contracts.models import Snapshot
|
|
44
|
+
from code_atlas.discovery.identity import identity_key_for_root
|
|
45
|
+
from code_atlas.federation.model import (
|
|
46
|
+
FEDERATION_RESOLVER_ID,
|
|
47
|
+
MAX_GRAPH_DEPTH,
|
|
48
|
+
MIN_FEDERATION_MEMBERS,
|
|
49
|
+
EntityNode,
|
|
50
|
+
FederatedEdge,
|
|
51
|
+
FederatedGraph,
|
|
52
|
+
FederatedView,
|
|
53
|
+
FederationMember,
|
|
54
|
+
FederationWorkspace,
|
|
55
|
+
InvalidKeyError,
|
|
56
|
+
InvalidPinError,
|
|
57
|
+
MemberNotFoundError,
|
|
58
|
+
RepositoryNotFoundError,
|
|
59
|
+
SharedOccurrence,
|
|
60
|
+
SnapshotNotFoundError,
|
|
61
|
+
SnapshotNotReadyError,
|
|
62
|
+
ViewMember,
|
|
63
|
+
WorkspaceExistsError,
|
|
64
|
+
WorkspaceNotFoundError,
|
|
65
|
+
WorkspaceSizeError,
|
|
66
|
+
compute_view_id,
|
|
67
|
+
)
|
|
68
|
+
from code_atlas.federation.store import FederationStore
|
|
69
|
+
from code_atlas.storage.migrations import schema_version
|
|
70
|
+
from code_atlas.storage.store import Store
|
|
71
|
+
|
|
72
|
+
MAX_WORKSPACE_NAME = 120
|
|
73
|
+
|
|
74
|
+
# Snapshots que uma view pode ler: READY (publicado) ou STALE (pin
|
|
75
|
+
# legítimo — congelar uma versão antiga é o propósito do pin). BUILDING
|
|
76
|
+
# é transiente; FAILED não tem dados confiáveis.
|
|
77
|
+
_READABLE_STATUSES = frozenset(
|
|
78
|
+
{str(SnapshotStatus.READY), str(SnapshotStatus.STALE)}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def shared_key_of(identity_key: str) -> str | None:
|
|
83
|
+
"""``identity_key`` lógica sem o segmento ``repository``.
|
|
84
|
+
|
|
85
|
+
``dom:<domain>/<repo>/<root>/<catalog>/<ns>/<env>/<qname>[/<snap>]``
|
|
86
|
+
→ ``dom:<domain>/<root>/<catalog>/<ns>/<env>/<qname>``. O segmento
|
|
87
|
+
repo é o índice 1 — separadores dentro de componentes são
|
|
88
|
+
percent-encodados, logo split em ``/`` é seguro. ``None`` quando a
|
|
89
|
+
chave não é ``dom:`` bem-formada (nunca fabrica identidade).
|
|
90
|
+
"""
|
|
91
|
+
parts = identity_key.split("/")
|
|
92
|
+
if len(parts) < 7 or not parts[0].startswith("dom:"):
|
|
93
|
+
return None
|
|
94
|
+
return "/".join([parts[0], *parts[2:7]])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def normalize_lookup_key(key: str) -> str:
|
|
98
|
+
"""Normaliza a chave pedida para a forma compartilhada (6 segmentos).
|
|
99
|
+
|
|
100
|
+
Aceita a ``identity_key`` completa (7 segmentos — repo descartado),
|
|
101
|
+
a ``canonical_key`` (8 — repo e snapshot descartados) ou a forma
|
|
102
|
+
compartilhada já derivada (6). Qualquer outra forma é
|
|
103
|
+
``InvalidKeyError`` — nunca match por substring/nome nu.
|
|
104
|
+
"""
|
|
105
|
+
parts = key.split("/")
|
|
106
|
+
if not parts or not parts[0].startswith("dom:"):
|
|
107
|
+
raise InvalidKeyError(f"not a domain identity key: {key[:80]!r}")
|
|
108
|
+
if len(parts) == 6:
|
|
109
|
+
return key
|
|
110
|
+
if len(parts) in (7, 8):
|
|
111
|
+
return "/".join([parts[0], *parts[2:7]])
|
|
112
|
+
raise InvalidKeyError(
|
|
113
|
+
f"domain identity key expects 6-8 segments, got {len(parts)}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Federation:
|
|
118
|
+
"""Fachada de federação sobre uma conexão sqlite3.
|
|
119
|
+
|
|
120
|
+
Métodos ``create_workspace``/``add_member``/``pin_member``/
|
|
121
|
+
``unpin_member`` escrevem — cada um na própria transação curta
|
|
122
|
+
(``with conn:``). Todos os demais são somente leitura.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
126
|
+
self._conn = conn
|
|
127
|
+
self._store = Store(conn)
|
|
128
|
+
self._fstore = FederationStore(conn)
|
|
129
|
+
|
|
130
|
+
# --- workspaces (CRUD — superfície CLI) ---------------------------
|
|
131
|
+
|
|
132
|
+
def create_workspace(self, name: str) -> FederationWorkspace:
|
|
133
|
+
"""Cria workspace nomeado; nome duplicado → ``WorkspaceExistsError``."""
|
|
134
|
+
clean = name.strip()
|
|
135
|
+
if not clean or len(clean) > MAX_WORKSPACE_NAME:
|
|
136
|
+
raise InvalidPinError(
|
|
137
|
+
f"workspace name must be 1..{MAX_WORKSPACE_NAME} chars"
|
|
138
|
+
)
|
|
139
|
+
with self._conn:
|
|
140
|
+
try:
|
|
141
|
+
ws_id = self._fstore.create_workspace(clean)
|
|
142
|
+
except sqlite3.IntegrityError as exc:
|
|
143
|
+
raise WorkspaceExistsError(
|
|
144
|
+
f"federation workspace already exists: {clean!r}"
|
|
145
|
+
) from exc
|
|
146
|
+
return FederationWorkspace(id=ws_id, name=clean, created_at="")
|
|
147
|
+
|
|
148
|
+
def get_workspace(self, ref: str | int) -> FederationWorkspace:
|
|
149
|
+
"""Workspace por id (int/str dígito) ou nome — 404 tipado."""
|
|
150
|
+
ws: FederationWorkspace | None = None
|
|
151
|
+
if isinstance(ref, int) or str(ref).isdigit():
|
|
152
|
+
ws = self._fstore.workspace_by_id(int(ref))
|
|
153
|
+
if ws is None and not (isinstance(ref, int) or str(ref).isdigit()):
|
|
154
|
+
ws = self._fstore.workspace_by_name(str(ref))
|
|
155
|
+
if ws is None:
|
|
156
|
+
raise WorkspaceNotFoundError(f"federation workspace: {ref!r}")
|
|
157
|
+
return ws
|
|
158
|
+
|
|
159
|
+
def list_workspaces(self) -> list[FederationWorkspace]:
|
|
160
|
+
return self._fstore.list_workspaces()
|
|
161
|
+
|
|
162
|
+
def members_of(self, workspace_id: int) -> list[FederationMember]:
|
|
163
|
+
return self._fstore.list_members(workspace_id)
|
|
164
|
+
|
|
165
|
+
# --- members ------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def add_member(
|
|
168
|
+
self, ws: FederationWorkspace, repository_id: int
|
|
169
|
+
) -> FederationMember:
|
|
170
|
+
"""Registra repo no workspace — idempotente (UNIQUE + OR IGNORE).
|
|
171
|
+
|
|
172
|
+
Rejeita repo inexistente: federação só agrupa repositórios **já
|
|
173
|
+
indexados** — nenhuma descoberta de diretório, nenhum registro
|
|
174
|
+
implícito.
|
|
175
|
+
"""
|
|
176
|
+
if self._store.repository_by_id(repository_id) is None:
|
|
177
|
+
raise RepositoryNotFoundError(
|
|
178
|
+
f"repository not indexed: {repository_id}"
|
|
179
|
+
)
|
|
180
|
+
with self._conn:
|
|
181
|
+
self._fstore.add_member(ws.id, repository_id)
|
|
182
|
+
member = self._fstore.member_for(ws.id, repository_id)
|
|
183
|
+
assert member is not None
|
|
184
|
+
return member
|
|
185
|
+
|
|
186
|
+
def remove_member(
|
|
187
|
+
self, ws: FederationWorkspace, repository_id: int
|
|
188
|
+
) -> None:
|
|
189
|
+
with self._conn:
|
|
190
|
+
if not self._fstore.remove_member(ws.id, repository_id):
|
|
191
|
+
raise MemberNotFoundError(
|
|
192
|
+
f"repository {repository_id} is not a member of "
|
|
193
|
+
f"{ws.name!r}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def pin_member(
|
|
197
|
+
self,
|
|
198
|
+
ws: FederationWorkspace,
|
|
199
|
+
repository_id: int,
|
|
200
|
+
snapshot_id: int,
|
|
201
|
+
) -> FederationMember:
|
|
202
|
+
"""Pina o membro num snapshot legível **daquele repo**.
|
|
203
|
+
|
|
204
|
+
READY e STALE são aceitos (pin = congelar versão); BUILDING/
|
|
205
|
+
FAILED são rejeitados — pin nunca aponta para dado incompleto.
|
|
206
|
+
"""
|
|
207
|
+
if self._fstore.member_for(ws.id, repository_id) is None:
|
|
208
|
+
raise MemberNotFoundError(
|
|
209
|
+
f"repository {repository_id} is not a member of {ws.name!r}"
|
|
210
|
+
)
|
|
211
|
+
snap = self._store.snapshot_by_id(snapshot_id)
|
|
212
|
+
if snap is None or snap.repository_id != repository_id:
|
|
213
|
+
raise SnapshotNotFoundError(
|
|
214
|
+
f"snapshot {snapshot_id} not found for repository "
|
|
215
|
+
f"{repository_id}"
|
|
216
|
+
)
|
|
217
|
+
if str(snap.status) not in _READABLE_STATUSES:
|
|
218
|
+
raise SnapshotNotReadyError(
|
|
219
|
+
f"snapshot {snapshot_id} is {snap.status}, "
|
|
220
|
+
"cannot pin an unreadable snapshot"
|
|
221
|
+
)
|
|
222
|
+
with self._conn:
|
|
223
|
+
self._fstore.set_pinned_snapshot(ws.id, repository_id, snapshot_id)
|
|
224
|
+
member = self._fstore.member_for(ws.id, repository_id)
|
|
225
|
+
assert member is not None
|
|
226
|
+
return member
|
|
227
|
+
|
|
228
|
+
def unpin_member(
|
|
229
|
+
self, ws: FederationWorkspace, repository_id: int
|
|
230
|
+
) -> FederationMember:
|
|
231
|
+
"""Limpa o pin — membro volta a seguir o READY mais recente."""
|
|
232
|
+
if self._fstore.member_for(ws.id, repository_id) is None:
|
|
233
|
+
raise MemberNotFoundError(
|
|
234
|
+
f"repository {repository_id} is not a member of {ws.name!r}"
|
|
235
|
+
)
|
|
236
|
+
with self._conn:
|
|
237
|
+
self._fstore.set_pinned_snapshot(ws.id, repository_id, None)
|
|
238
|
+
member = self._fstore.member_for(ws.id, repository_id)
|
|
239
|
+
assert member is not None
|
|
240
|
+
return member
|
|
241
|
+
|
|
242
|
+
def resolve_repository(self, ref: str) -> int:
|
|
243
|
+
"""``<repo-path-or-id>`` → repository_id já indexado.
|
|
244
|
+
|
|
245
|
+
Ordem: id numérico → ``identity_key``/``root_handle`` literal →
|
|
246
|
+
path on-disk (probe git de ``identity_key_for_root`` + fallback
|
|
247
|
+
``root_handle`` resolvido). Nada é criado: referência que não
|
|
248
|
+
resolve para repo indexado é ``RepositoryNotFoundError``.
|
|
249
|
+
"""
|
|
250
|
+
if ref.isdigit():
|
|
251
|
+
rid = int(ref)
|
|
252
|
+
if self._store.repository_by_id(rid) is not None:
|
|
253
|
+
return rid
|
|
254
|
+
raise RepositoryNotFoundError(f"repository not indexed: {rid}")
|
|
255
|
+
resolved = self._store.repository_id_for(ref)
|
|
256
|
+
if resolved is not None:
|
|
257
|
+
return resolved
|
|
258
|
+
candidate = Path(ref).expanduser()
|
|
259
|
+
if not candidate.is_absolute():
|
|
260
|
+
candidate = Path.cwd() / candidate
|
|
261
|
+
candidate = candidate.resolve()
|
|
262
|
+
resolved = self._store.repository_id_for(
|
|
263
|
+
identity_key_for_root(candidate))
|
|
264
|
+
if resolved is not None:
|
|
265
|
+
return resolved
|
|
266
|
+
resolved = self._store.repository_id_for(str(candidate))
|
|
267
|
+
if resolved is not None:
|
|
268
|
+
return resolved
|
|
269
|
+
raise RepositoryNotFoundError(
|
|
270
|
+
f"no indexed repository for {ref!r} — index it first"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# --- view -----------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def resolve_view(
|
|
276
|
+
self,
|
|
277
|
+
ws: FederationWorkspace,
|
|
278
|
+
pins: Mapping[int, int] | None = None,
|
|
279
|
+
) -> FederatedView:
|
|
280
|
+
"""Materializa ``{repo_id: snapshot_id}`` do workspace.
|
|
281
|
+
|
|
282
|
+
Precedência por membro: ``pins`` (override do request) → pin
|
|
283
|
+
persistido → READY mais recente. Pins de override exigem repo
|
|
284
|
+
membro + snapshot legível do mesmo repo; um pin persistido é
|
|
285
|
+
honrado mesmo que o snapshot tenha virado STALE (congelar é o
|
|
286
|
+
propósito). Membros sem snapshot legível entram na view como
|
|
287
|
+
``snapshot_id=None`` — ausência declarada, nunca inventada.
|
|
288
|
+
"""
|
|
289
|
+
members = self._fstore.list_members(ws.id)
|
|
290
|
+
if len(members) < MIN_FEDERATION_MEMBERS:
|
|
291
|
+
raise WorkspaceSizeError(
|
|
292
|
+
f"federation {ws.name!r} has {len(members)} member(s) — "
|
|
293
|
+
f"requires >= {MIN_FEDERATION_MEMBERS} indexed repositories"
|
|
294
|
+
)
|
|
295
|
+
overrides = dict(pins or {})
|
|
296
|
+
member_repos = {m.repository_id for m in members}
|
|
297
|
+
extra = sorted(set(overrides) - member_repos)
|
|
298
|
+
if extra:
|
|
299
|
+
raise InvalidPinError(
|
|
300
|
+
f"pin override for non-member repositories: {extra}"
|
|
301
|
+
)
|
|
302
|
+
view_members: list[ViewMember] = []
|
|
303
|
+
resolved: dict[int, int | None] = {}
|
|
304
|
+
resolved_snaps: dict[int, Snapshot | None] = {}
|
|
305
|
+
for m in members:
|
|
306
|
+
vm, snap = self._resolve_member(
|
|
307
|
+
m, overrides.get(m.repository_id))
|
|
308
|
+
view_members.append(vm)
|
|
309
|
+
resolved[m.repository_id] = vm.snapshot_id
|
|
310
|
+
resolved_snaps[m.repository_id] = snap
|
|
311
|
+
return FederatedView(
|
|
312
|
+
workspace_id=ws.id,
|
|
313
|
+
workspace_name=ws.name,
|
|
314
|
+
view_id=compute_view_id(
|
|
315
|
+
resolved,
|
|
316
|
+
identities=self._fstore.repo_identities(resolved),
|
|
317
|
+
member_versions={
|
|
318
|
+
rid: _analyzer_digest(resolved_snaps.get(rid))
|
|
319
|
+
for rid in resolved
|
|
320
|
+
},
|
|
321
|
+
schema=schema_version(self._conn),
|
|
322
|
+
),
|
|
323
|
+
members=tuple(view_members),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def _resolve_member(
|
|
327
|
+
self, member: FederationMember, override: int | None
|
|
328
|
+
) -> tuple[ViewMember, Snapshot | None]:
|
|
329
|
+
"""Resolve o membro — ``(ViewMember, snapshot escolhido|None)``.
|
|
330
|
+
|
|
331
|
+
O ``Snapshot`` acompanha o retorno para que a identidade da view
|
|
332
|
+
carregue o digest das ``analyzer_versions`` **sem re-query** —
|
|
333
|
+
a versão dos adapters/resolvers que produziram os dados é parte
|
|
334
|
+
da cache key (lf-0134).
|
|
335
|
+
"""
|
|
336
|
+
if override is not None:
|
|
337
|
+
snap = self._store.snapshot_by_id(override)
|
|
338
|
+
if snap is None or snap.repository_id != member.repository_id:
|
|
339
|
+
raise SnapshotNotFoundError(
|
|
340
|
+
f"snapshot {override} not found for repository "
|
|
341
|
+
f"{member.repository_id}"
|
|
342
|
+
)
|
|
343
|
+
if str(snap.status) not in _READABLE_STATUSES:
|
|
344
|
+
raise SnapshotNotReadyError(
|
|
345
|
+
f"snapshot {override} is {snap.status}, "
|
|
346
|
+
"not readable"
|
|
347
|
+
)
|
|
348
|
+
return ViewMember(
|
|
349
|
+
repository_id=member.repository_id,
|
|
350
|
+
snapshot_id=snap.id,
|
|
351
|
+
pinned=True,
|
|
352
|
+
resolution="pin-override",
|
|
353
|
+
snapshot_status=str(snap.status),
|
|
354
|
+
), snap
|
|
355
|
+
if member.pinned_snapshot_id is not None:
|
|
356
|
+
snap = self._store.snapshot_by_id(member.pinned_snapshot_id)
|
|
357
|
+
if snap is not None and snap.repository_id == member.repository_id:
|
|
358
|
+
return ViewMember(
|
|
359
|
+
repository_id=member.repository_id,
|
|
360
|
+
snapshot_id=snap.id,
|
|
361
|
+
pinned=True,
|
|
362
|
+
resolution="pinned",
|
|
363
|
+
snapshot_status=str(snap.status),
|
|
364
|
+
), snap
|
|
365
|
+
# pin apontando para snapshot inexistente/alheio só é
|
|
366
|
+
# possível via adulteração manual — reporta, não adivinha.
|
|
367
|
+
return ViewMember(
|
|
368
|
+
repository_id=member.repository_id,
|
|
369
|
+
snapshot_id=None,
|
|
370
|
+
pinned=True,
|
|
371
|
+
resolution="pin-invalid",
|
|
372
|
+
snapshot_status="",
|
|
373
|
+
), None
|
|
374
|
+
snap = self._store.latest_ready_snapshot(member.repository_id)
|
|
375
|
+
if snap is None:
|
|
376
|
+
return ViewMember(
|
|
377
|
+
repository_id=member.repository_id,
|
|
378
|
+
snapshot_id=None,
|
|
379
|
+
pinned=False,
|
|
380
|
+
resolution="no-ready-snapshot",
|
|
381
|
+
snapshot_status="",
|
|
382
|
+
), None
|
|
383
|
+
return ViewMember(
|
|
384
|
+
repository_id=member.repository_id,
|
|
385
|
+
snapshot_id=snap.id,
|
|
386
|
+
pinned=False,
|
|
387
|
+
resolution="latest-ready",
|
|
388
|
+
snapshot_status=str(snap.status),
|
|
389
|
+
), snap
|
|
390
|
+
|
|
391
|
+
# --- leitura federada -------------------------------------------------
|
|
392
|
+
|
|
393
|
+
def _member_occurrences(
|
|
394
|
+
self, view: FederatedView
|
|
395
|
+
) -> dict[int, list[SharedOccurrence]]:
|
|
396
|
+
"""Ocorrências de domínio por membro — ancoradas no snapshot da view."""
|
|
397
|
+
out: dict[int, list[SharedOccurrence]] = {}
|
|
398
|
+
for repo_id, snap_id in view.snapshot_map.items():
|
|
399
|
+
occs: list[SharedOccurrence] = []
|
|
400
|
+
for row in self._fstore.snapshot_entity_occurrences(snap_id):
|
|
401
|
+
skey = shared_key_of(str(row["identity_key"]))
|
|
402
|
+
if skey is None:
|
|
403
|
+
continue # chave corrompida — nunca fabrica identidade
|
|
404
|
+
occs.append(
|
|
405
|
+
SharedOccurrence(
|
|
406
|
+
repository_id=repo_id,
|
|
407
|
+
snapshot_id=snap_id,
|
|
408
|
+
entity_id=int(row["entity_id"]),
|
|
409
|
+
occurrence_id=int(row["occ_id"]),
|
|
410
|
+
domain=str(row["domain"]),
|
|
411
|
+
kind=str(row["kind"]),
|
|
412
|
+
identity_key=str(row["identity_key"]),
|
|
413
|
+
shared_key=skey,
|
|
414
|
+
qualified_name=str(row["qualified_name"]),
|
|
415
|
+
name=str(row["name"]),
|
|
416
|
+
catalog=str(row["catalog"]),
|
|
417
|
+
namespace=str(row["namespace"]),
|
|
418
|
+
environment=str(row["environment"]),
|
|
419
|
+
path=str(row["source_path"] or ""),
|
|
420
|
+
line=(
|
|
421
|
+
None if row["line"] is None else int(row["line"])
|
|
422
|
+
),
|
|
423
|
+
col=(None if row["col"] is None else int(row["col"])),
|
|
424
|
+
extraction_status=str(row["extraction_status"]),
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
out[repo_id] = occs
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
def entity_occurrences(
|
|
431
|
+
self,
|
|
432
|
+
ws: FederationWorkspace,
|
|
433
|
+
key: str,
|
|
434
|
+
pins: Mapping[int, int] | None = None,
|
|
435
|
+
) -> dict[str, object]:
|
|
436
|
+
"""Quais repos contêm a identidade pedida — ocorrências por repo."""
|
|
437
|
+
skey = normalize_lookup_key(key)
|
|
438
|
+
view = self.resolve_view(ws, pins)
|
|
439
|
+
items: list[SharedOccurrence] = []
|
|
440
|
+
for occs in self._member_occurrences(view).values():
|
|
441
|
+
items.extend(o for o in occs if o.shared_key == skey)
|
|
442
|
+
items.sort(key=lambda o: (o.repository_id, o.entity_id, o.occurrence_id))
|
|
443
|
+
repos = sorted({o.repository_id for o in items})
|
|
444
|
+
payload = view.to_dict()
|
|
445
|
+
payload.update(
|
|
446
|
+
{
|
|
447
|
+
"key": key,
|
|
448
|
+
"shared_key": skey,
|
|
449
|
+
"items": [o.to_dict() for o in items],
|
|
450
|
+
"repositories_with_entity": repos,
|
|
451
|
+
"total": len(items),
|
|
452
|
+
}
|
|
453
|
+
)
|
|
454
|
+
return payload
|
|
455
|
+
|
|
456
|
+
def graph(
|
|
457
|
+
self,
|
|
458
|
+
ws: FederationWorkspace,
|
|
459
|
+
depth: int = 1,
|
|
460
|
+
limit: int = 500,
|
|
461
|
+
pins: Mapping[int, int] | None = None,
|
|
462
|
+
) -> FederatedGraph:
|
|
463
|
+
"""Grafo federado: sementes compartilhadas + BFS limitado.
|
|
464
|
+
|
|
465
|
+
Sementes = entidades cuja ``shared_key`` aparece em >=2 repos
|
|
466
|
+
membros (o único vínculo cross-repo honesto desta fase).
|
|
467
|
+
``depth`` = hops de BFS sobre arestas intra-snapshot
|
|
468
|
+
(``domain_relations`` entidade↔entidade, ambas as direções) **e**
|
|
469
|
+
links ``shared_entity``; ``limit`` encima nós+arestas —
|
|
470
|
+
``truncated`` declara o corte.
|
|
471
|
+
"""
|
|
472
|
+
depth = max(0, min(int(depth), MAX_GRAPH_DEPTH))
|
|
473
|
+
view = self.resolve_view(ws, pins)
|
|
474
|
+
per_repo = self._member_occurrences(view)
|
|
475
|
+
entities, entity_repo, entity_paths, shared_repos, occ_to_entity = (
|
|
476
|
+
_collect_entities(per_repo))
|
|
477
|
+
|
|
478
|
+
# arestas intra-snapshot (entidade↔entidade apenas — a camada de
|
|
479
|
+
# domínio desta fase não expande endpoints de símbolo)
|
|
480
|
+
adjacency: dict[int, set[int]] = {}
|
|
481
|
+
intra = self._intra_edges(view, occ_to_entity, adjacency)
|
|
482
|
+
shared_edges, seeds = _shared_edges(
|
|
483
|
+
per_repo, shared_repos, adjacency)
|
|
484
|
+
|
|
485
|
+
# BFS a partir das sementes — hops contam intra e shared.
|
|
486
|
+
visited, truncated = _bfs_expand(seeds, adjacency, depth, limit)
|
|
487
|
+
node_ids = sorted(visited)[:limit]
|
|
488
|
+
node_set = set(node_ids)
|
|
489
|
+
edges = [
|
|
490
|
+
e for e in intra + shared_edges
|
|
491
|
+
if e.from_entity_id in node_set and e.to_entity_id in node_set
|
|
492
|
+
]
|
|
493
|
+
if len(edges) > limit:
|
|
494
|
+
edges = edges[:limit]
|
|
495
|
+
truncated = True
|
|
496
|
+
nodes = tuple(
|
|
497
|
+
EntityNode(
|
|
498
|
+
entity_id=eid,
|
|
499
|
+
repository_id=entity_repo[eid],
|
|
500
|
+
snapshot_id=view.snapshot_map[entity_repo[eid]],
|
|
501
|
+
domain=entities[eid].domain,
|
|
502
|
+
kind=entities[eid].kind,
|
|
503
|
+
identity_key=entities[eid].identity_key,
|
|
504
|
+
shared_key=entities[eid].shared_key,
|
|
505
|
+
qualified_name=entities[eid].qualified_name,
|
|
506
|
+
name=entities[eid].name,
|
|
507
|
+
paths=tuple(sorted(entity_paths.get(eid, ()))),
|
|
508
|
+
)
|
|
509
|
+
for eid in node_ids
|
|
510
|
+
)
|
|
511
|
+
stats = {
|
|
512
|
+
"member_repositories": len(view.snapshot_map),
|
|
513
|
+
"intra_edges": len(intra),
|
|
514
|
+
"shared_edges": len(shared_edges),
|
|
515
|
+
"candidate_entities": len(entities),
|
|
516
|
+
}
|
|
517
|
+
return FederatedGraph(
|
|
518
|
+
view=view,
|
|
519
|
+
nodes=nodes,
|
|
520
|
+
edges=tuple(edges),
|
|
521
|
+
depth=depth,
|
|
522
|
+
seeds=len(seeds),
|
|
523
|
+
truncated=truncated,
|
|
524
|
+
stats=stats,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
def _intra_edges(
|
|
528
|
+
self,
|
|
529
|
+
view: FederatedView,
|
|
530
|
+
occ_to_entity: Mapping[int, tuple[int, int]],
|
|
531
|
+
adjacency: dict[int, set[int]],
|
|
532
|
+
) -> list[FederatedEdge]:
|
|
533
|
+
"""Arestas reais de ``domain_relations`` dentro de cada snapshot."""
|
|
534
|
+
intra: list[FederatedEdge] = []
|
|
535
|
+
seen: set[tuple[int, int, str]] = set()
|
|
536
|
+
for snap_id in view.snapshot_map.values():
|
|
537
|
+
for r in self._store.domain_relations_snapshot(snap_id):
|
|
538
|
+
fe, te = r["from_entity_occ_id"], r["to_entity_occ_id"]
|
|
539
|
+
if fe is None or te is None:
|
|
540
|
+
continue
|
|
541
|
+
src = occ_to_entity.get(int(fe))
|
|
542
|
+
dst = occ_to_entity.get(int(te))
|
|
543
|
+
if src is None or dst is None:
|
|
544
|
+
continue
|
|
545
|
+
edge_key = (src[1], dst[1], str(r["type"]))
|
|
546
|
+
if edge_key in seen:
|
|
547
|
+
continue
|
|
548
|
+
seen.add(edge_key)
|
|
549
|
+
intra.append(
|
|
550
|
+
FederatedEdge(
|
|
551
|
+
kind="intra",
|
|
552
|
+
relation_id=int(r["id"]),
|
|
553
|
+
from_entity_id=src[1],
|
|
554
|
+
to_entity_id=dst[1],
|
|
555
|
+
from_repo=src[0],
|
|
556
|
+
to_repo=dst[0],
|
|
557
|
+
relation_type=str(r["type"]),
|
|
558
|
+
resolution_status=str(r["resolution_status"]),
|
|
559
|
+
confidence_class=str(r["confidence_class"]),
|
|
560
|
+
resolver=str(r["resolver"]),
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
adjacency.setdefault(src[1], set()).add(dst[1])
|
|
564
|
+
adjacency.setdefault(dst[1], set()).add(src[1])
|
|
565
|
+
return intra
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _analyzer_digest(snap: Snapshot | None) -> str:
|
|
569
|
+
"""sha256 das ``analyzer_versions`` do snapshot resolvido.
|
|
570
|
+
|
|
571
|
+
Componente "resolver/adapter versions" da cache key da view
|
|
572
|
+
(lf-0134): o digest das versões de parser/extrator/resolver/adapter
|
|
573
|
+
que produziram os rows daquele snapshot. ``None`` (membro sem
|
|
574
|
+
snapshot legível) → ``""`` — ausência declarada, nunca fabricada.
|
|
575
|
+
"""
|
|
576
|
+
if snap is None:
|
|
577
|
+
return ""
|
|
578
|
+
return hashlib.sha256(
|
|
579
|
+
json.dumps(snap.analyzer_versions, sort_keys=True).encode("utf-8")
|
|
580
|
+
).hexdigest()
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _collect_entities(
|
|
584
|
+
per_repo: Mapping[int, list[SharedOccurrence]],
|
|
585
|
+
) -> tuple[
|
|
586
|
+
dict[int, SharedOccurrence],
|
|
587
|
+
dict[int, int],
|
|
588
|
+
dict[int, set[str]],
|
|
589
|
+
dict[str, set[int]],
|
|
590
|
+
dict[int, tuple[int, int]],
|
|
591
|
+
]:
|
|
592
|
+
"""Índices por entidade a partir das ocorrências por membro."""
|
|
593
|
+
occ_to_entity: dict[int, tuple[int, int]] = {} # occ → (repo, ent)
|
|
594
|
+
entities: dict[int, SharedOccurrence] = {}
|
|
595
|
+
entity_paths: dict[int, set[str]] = {}
|
|
596
|
+
shared_repos: dict[str, set[int]] = {}
|
|
597
|
+
entity_repo: dict[int, int] = {}
|
|
598
|
+
for repo_id, occs in per_repo.items():
|
|
599
|
+
for o in occs:
|
|
600
|
+
occ_to_entity[o.occurrence_id] = (repo_id, o.entity_id)
|
|
601
|
+
entities[o.entity_id] = o
|
|
602
|
+
entity_repo[o.entity_id] = repo_id
|
|
603
|
+
if o.path:
|
|
604
|
+
entity_paths.setdefault(o.entity_id, set()).add(o.path)
|
|
605
|
+
shared_repos.setdefault(o.shared_key, set()).add(repo_id)
|
|
606
|
+
return entities, entity_repo, entity_paths, shared_repos, occ_to_entity
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _shared_edges(
|
|
610
|
+
per_repo: Mapping[int, list[SharedOccurrence]],
|
|
611
|
+
shared_repos: Mapping[str, set[int]],
|
|
612
|
+
adjacency: dict[int, set[int]],
|
|
613
|
+
) -> tuple[list[FederatedEdge], set[int]]:
|
|
614
|
+
"""``shared_entity`` computadas: mesma shared_key em repos distintos."""
|
|
615
|
+
edges: list[FederatedEdge] = []
|
|
616
|
+
seeds: set[int] = set()
|
|
617
|
+
seen: set[frozenset[int]] = set()
|
|
618
|
+
shared_entities = {
|
|
619
|
+
skey for skey, repos in shared_repos.items() if len(repos) > 1
|
|
620
|
+
}
|
|
621
|
+
for skey in sorted(shared_entities):
|
|
622
|
+
members = [
|
|
623
|
+
o for occs in per_repo.values()
|
|
624
|
+
for o in occs if o.shared_key == skey
|
|
625
|
+
]
|
|
626
|
+
for i, a in enumerate(members):
|
|
627
|
+
seeds.add(a.entity_id)
|
|
628
|
+
for b in members[i + 1:]:
|
|
629
|
+
if a.repository_id == b.repository_id:
|
|
630
|
+
continue
|
|
631
|
+
pair = frozenset((a.entity_id, b.entity_id))
|
|
632
|
+
if pair in seen:
|
|
633
|
+
continue
|
|
634
|
+
seen.add(pair)
|
|
635
|
+
edges.append(
|
|
636
|
+
FederatedEdge(
|
|
637
|
+
kind="shared_entity",
|
|
638
|
+
from_entity_id=a.entity_id,
|
|
639
|
+
to_entity_id=b.entity_id,
|
|
640
|
+
from_repo=a.repository_id,
|
|
641
|
+
to_repo=b.repository_id,
|
|
642
|
+
relation_type="shared_entity",
|
|
643
|
+
resolution_status="resolved",
|
|
644
|
+
confidence_class="resolved",
|
|
645
|
+
resolver=FEDERATION_RESOLVER_ID,
|
|
646
|
+
computed=True,
|
|
647
|
+
)
|
|
648
|
+
)
|
|
649
|
+
adjacency.setdefault(a.entity_id, set()).add(b.entity_id)
|
|
650
|
+
adjacency.setdefault(b.entity_id, set()).add(a.entity_id)
|
|
651
|
+
return edges, seeds
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _bfs_expand(
|
|
655
|
+
seeds: set[int],
|
|
656
|
+
adjacency: Mapping[int, set[int]],
|
|
657
|
+
depth: int,
|
|
658
|
+
limit: int,
|
|
659
|
+
) -> tuple[set[int], bool]:
|
|
660
|
+
"""BFS limitado: ``depth`` hops, ``limit`` nós — retorna visitados."""
|
|
661
|
+
visited: set[int] = set(seeds)
|
|
662
|
+
frontier = set(seeds)
|
|
663
|
+
hops = 0
|
|
664
|
+
while frontier and hops < depth:
|
|
665
|
+
hops += 1
|
|
666
|
+
nxt: set[int] = set()
|
|
667
|
+
for ent in sorted(frontier):
|
|
668
|
+
for nb in adjacency.get(ent, ()):
|
|
669
|
+
if nb not in visited:
|
|
670
|
+
if len(visited) >= limit:
|
|
671
|
+
return visited, True
|
|
672
|
+
visited.add(nb)
|
|
673
|
+
nxt.add(nb)
|
|
674
|
+
frontier = nxt
|
|
675
|
+
return visited, False
|
|
676
|
+
|