code-atlas-forge 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. code_atlas/__init__.py +6 -0
  2. code_atlas/__main__.py +6 -0
  3. code_atlas/api/__init__.py +5 -0
  4. code_atlas/api/handlers.py +1514 -0
  5. code_atlas/api/server.py +394 -0
  6. code_atlas/cli.py +2084 -0
  7. code_atlas/config.py +149 -0
  8. code_atlas/context/__init__.py +66 -0
  9. code_atlas/context/contracts.py +277 -0
  10. code_atlas/context/domain.py +760 -0
  11. code_atlas/context/engine.py +442 -0
  12. code_atlas/context/expand.py +133 -0
  13. code_atlas/context/renderers.py +71 -0
  14. code_atlas/context/tokenizer.py +37 -0
  15. code_atlas/contracts/__init__.py +38 -0
  16. code_atlas/contracts/enums.py +67 -0
  17. code_atlas/contracts/models.py +175 -0
  18. code_atlas/contracts/ports.py +70 -0
  19. code_atlas/discovery/__init__.py +25 -0
  20. code_atlas/discovery/gitignore.py +119 -0
  21. code_atlas/discovery/identity.py +52 -0
  22. code_atlas/discovery/models.py +204 -0
  23. code_atlas/discovery/scanner.py +495 -0
  24. code_atlas/domains/__init__.py +107 -0
  25. code_atlas/domains/api/__init__.py +28 -0
  26. code_atlas/domains/api/adapter.py +405 -0
  27. code_atlas/domains/api/common.py +308 -0
  28. code_atlas/domains/api/consumers.py +224 -0
  29. code_atlas/domains/api/emit.py +231 -0
  30. code_atlas/domains/api/openapi.py +398 -0
  31. code_atlas/domains/api/routes.py +582 -0
  32. code_atlas/domains/api/yaml_subset.py +683 -0
  33. code_atlas/domains/aws/__init__.py +28 -0
  34. code_atlas/domains/aws/adapter.py +382 -0
  35. code_atlas/domains/aws/common.py +261 -0
  36. code_atlas/domains/aws/configs.py +1164 -0
  37. code_atlas/domains/aws/emit.py +411 -0
  38. code_atlas/domains/aws/pyfiles.py +2220 -0
  39. code_atlas/domains/aws/sqlbridge.py +448 -0
  40. code_atlas/domains/cicd/__init__.py +50 -0
  41. code_atlas/domains/cicd/adapter.py +762 -0
  42. code_atlas/domains/cicd/classify.py +136 -0
  43. code_atlas/domains/cicd/workflow.py +504 -0
  44. code_atlas/domains/cicd/yamlmini.py +946 -0
  45. code_atlas/domains/cli_surface.py +739 -0
  46. code_atlas/domains/contracts.py +274 -0
  47. code_atlas/domains/crossresolve.py +1208 -0
  48. code_atlas/domains/evidence.py +57 -0
  49. code_atlas/domains/identity.py +149 -0
  50. code_atlas/domains/model.py +202 -0
  51. code_atlas/domains/registry.py +250 -0
  52. code_atlas/domains/resolution.py +65 -0
  53. code_atlas/domains/spark/__init__.py +31 -0
  54. code_atlas/domains/spark/adapter.py +146 -0
  55. code_atlas/domains/spark/emit.py +344 -0
  56. code_atlas/domains/spark/extract.py +108 -0
  57. code_atlas/domains/spark/sqllit.py +192 -0
  58. code_atlas/domains/spark/walk.py +1943 -0
  59. code_atlas/domains/sql/__init__.py +30 -0
  60. code_atlas/domains/sql/adapter.py +382 -0
  61. code_atlas/domains/sql/extract.py +1271 -0
  62. code_atlas/domains/sql/nodes.py +264 -0
  63. code_atlas/domains/sql/parser.py +1510 -0
  64. code_atlas/domains/sql/tokens.py +318 -0
  65. code_atlas/domains/summarize.py +126 -0
  66. code_atlas/domains/terraform/__init__.py +20 -0
  67. code_atlas/domains/terraform/adapter.py +127 -0
  68. code_atlas/domains/terraform/catalog.py +43 -0
  69. code_atlas/domains/terraform/extract.py +1277 -0
  70. code_atlas/domains/terraform/hcl.py +538 -0
  71. code_atlas/domains/terraform/refs.py +181 -0
  72. code_atlas/domains/terraform/tfjson.py +163 -0
  73. code_atlas/domains/terraform/tfvars.py +98 -0
  74. code_atlas/domains/validation.py +120 -0
  75. code_atlas/env_override.py +27 -0
  76. code_atlas/eval/__init__.py +26 -0
  77. code_atlas/eval/domain_eval.py +681 -0
  78. code_atlas/eval/ground_truth.py +688 -0
  79. code_atlas/eval/impact_truth.py +758 -0
  80. code_atlas/eval/metrics.py +178 -0
  81. code_atlas/eval/pack_value.py +610 -0
  82. code_atlas/federation/__init__.py +72 -0
  83. code_atlas/federation/federation.py +676 -0
  84. code_atlas/federation/model.py +355 -0
  85. code_atlas/federation/store.py +194 -0
  86. code_atlas/gitbin.py +48 -0
  87. code_atlas/graph/__init__.py +20 -0
  88. code_atlas/graph/builder.py +378 -0
  89. code_atlas/graph/identity.py +54 -0
  90. code_atlas/graph/queries.py +47 -0
  91. code_atlas/graph/validation.py +92 -0
  92. code_atlas/impact/__init__.py +37 -0
  93. code_atlas/impact/blast_radius.py +171 -0
  94. code_atlas/impact/contract_analyzer.py +104 -0
  95. code_atlas/impact/contracts.py +315 -0
  96. code_atlas/impact/diff_reader.py +142 -0
  97. code_atlas/impact/domain_traversal.py +768 -0
  98. code_atlas/impact/engine.py +392 -0
  99. code_atlas/impact/packs.py +128 -0
  100. code_atlas/impact/report.py +136 -0
  101. code_atlas/impact/revision_binding.py +220 -0
  102. code_atlas/impact/risk_classifier.py +83 -0
  103. code_atlas/impact/snapshot_resolver.py +136 -0
  104. code_atlas/impact/symbol_diff.py +210 -0
  105. code_atlas/impact/test_selector.py +166 -0
  106. code_atlas/indexing/__init__.py +5 -0
  107. code_atlas/indexing/cache.py +396 -0
  108. code_atlas/indexing/change_detector.py +278 -0
  109. code_atlas/indexing/coordinator.py +1032 -0
  110. code_atlas/indexing/dependency_planner.py +268 -0
  111. code_atlas/indexing/persist.py +436 -0
  112. code_atlas/indexing/reuse.py +85 -0
  113. code_atlas/install/__init__.py +7 -0
  114. code_atlas/install/cli.py +168 -0
  115. code_atlas/install/lifecycle.py +286 -0
  116. code_atlas/mcp.py +674 -0
  117. code_atlas/observability/__init__.py +19 -0
  118. code_atlas/observability/logging.py +199 -0
  119. code_atlas/parsers/__init__.py +43 -0
  120. code_atlas/parsers/contracts.py +209 -0
  121. code_atlas/parsers/engine.py +125 -0
  122. code_atlas/parsers/languages/__init__.py +1 -0
  123. code_atlas/parsers/languages/python.py +391 -0
  124. code_atlas/parsers/languages/tsx.py +28 -0
  125. code_atlas/parsers/languages/typescript.py +909 -0
  126. code_atlas/parsers/registry.py +51 -0
  127. code_atlas/parsers/tree_sitter_runtime.py +118 -0
  128. code_atlas/resolvers/__init__.py +33 -0
  129. code_atlas/resolvers/bootstrap.py +246 -0
  130. code_atlas/resolvers/capabilities.py +33 -0
  131. code_atlas/resolvers/contracts.py +133 -0
  132. code_atlas/resolvers/coordinator.py +96 -0
  133. code_atlas/resolvers/enrichment.py +318 -0
  134. code_atlas/resolvers/lsp/__init__.py +26 -0
  135. code_atlas/resolvers/lsp/adapter.py +256 -0
  136. code_atlas/resolvers/lsp/lifecycle.py +251 -0
  137. code_atlas/resolvers/lsp/spawn.py +92 -0
  138. code_atlas/resolvers/lsp/transport.py +240 -0
  139. code_atlas/resolvers/registry.py +33 -0
  140. code_atlas/resolvers/scip/__init__.py +49 -0
  141. code_atlas/resolvers/scip/decoder.py +216 -0
  142. code_atlas/resolvers/scip/importer.py +248 -0
  143. code_atlas/resolvers/scip/loader.py +90 -0
  144. code_atlas/resolvers/scip/mapper.py +271 -0
  145. code_atlas/resolvers/scip/provenance.py +211 -0
  146. code_atlas/resolvers/scip/resolver.py +289 -0
  147. code_atlas/resolvers/scip/validation.py +101 -0
  148. code_atlas/resolvers/syntactic/__init__.py +8 -0
  149. code_atlas/resolvers/syntactic/resolver.py +254 -0
  150. code_atlas/search/__init__.py +32 -0
  151. code_atlas/search/contracts.py +176 -0
  152. code_atlas/search/domain.py +561 -0
  153. code_atlas/search/engine.py +530 -0
  154. code_atlas/search/federated.py +276 -0
  155. code_atlas/search/graph.py +282 -0
  156. code_atlas/search/hybrid.py +151 -0
  157. code_atlas/search/intent.py +48 -0
  158. code_atlas/search/lexical.py +176 -0
  159. code_atlas/search/refs.py +359 -0
  160. code_atlas/search/structural.py +505 -0
  161. code_atlas/search/tokenizer.py +43 -0
  162. code_atlas/search/vector/__init__.py +35 -0
  163. code_atlas/search/vector/contracts.py +76 -0
  164. code_atlas/search/vector/embeddings.py +96 -0
  165. code_atlas/search/vector/index.py +169 -0
  166. code_atlas/search/vector/provider.py +50 -0
  167. code_atlas/storage/__init__.py +14 -0
  168. code_atlas/storage/db.py +47 -0
  169. code_atlas/storage/migrations.py +552 -0
  170. code_atlas/storage/store.py +1045 -0
  171. code_atlas/ui/__init__.py +29 -0
  172. code_atlas/ui/i18n.py +143 -0
  173. code_atlas/ui/kit.py +689 -0
  174. code_atlas/ui/wizard.py +317 -0
  175. code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
  176. code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
  177. code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
  178. code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
  179. code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
  180. code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
  181. code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,38 @@
1
+ """Contratos de domínio do Code Atlas (modelos, enums, ports).
2
+
3
+ O domínio não importa CLI, web, MCP, UI ou bancos externos.
4
+ """
5
+
6
+ from code_atlas.contracts.enums import (
7
+ ConfidenceClass,
8
+ EdgeKind,
9
+ NodeKind,
10
+ ResolutionStatus,
11
+ SnapshotStatus,
12
+ )
13
+ from code_atlas.contracts.models import (
14
+ DomainSourceFile,
15
+ Evidence,
16
+ IndexJob,
17
+ LogicalSymbol,
18
+ Relation,
19
+ Repository,
20
+ Snapshot,
21
+ SymbolOccurrence,
22
+ )
23
+
24
+ __all__ = [
25
+ "ConfidenceClass",
26
+ "DomainSourceFile",
27
+ "EdgeKind",
28
+ "Evidence",
29
+ "IndexJob",
30
+ "LogicalSymbol",
31
+ "NodeKind",
32
+ "Relation",
33
+ "Repository",
34
+ "ResolutionStatus",
35
+ "Snapshot",
36
+ "SnapshotStatus",
37
+ "SymbolOccurrence",
38
+ ]
@@ -0,0 +1,67 @@
1
+ """Vocabulário canônico do domínio (Blueprint §5).
2
+
3
+ Valores devem casar exatamente com o Blueprint — não renomear nem estender
4
+ sem ADR. Valores futuros documentados (READS_FROM etc.) ficam de fora até
5
+ existir produtor real.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import StrEnum
11
+
12
+
13
+ class NodeKind(StrEnum):
14
+ """Tipos de nó do grafo (Blueprint §5 "Tipos iniciais")."""
15
+
16
+ REPOSITORY = "repository"
17
+ PACKAGE = "package"
18
+ MODULE = "module"
19
+ FILE = "file"
20
+ CLASS = "class"
21
+ INTERFACE = "interface"
22
+ FUNCTION = "function"
23
+ METHOD = "method"
24
+ SYMBOL = "symbol"
25
+ CONFIG = "config"
26
+ UNKNOWN = "unknown"
27
+
28
+
29
+ class EdgeKind(StrEnum):
30
+ """Vocabulário de arestas (Blueprint §5)."""
31
+
32
+ CONTAINS = "CONTAINS"
33
+ DEFINES = "DEFINES"
34
+ IMPORTS = "IMPORTS"
35
+ CALLS = "CALLS"
36
+ IMPLEMENTS = "IMPLEMENTS"
37
+ EXTENDS = "EXTENDS"
38
+ REFERENCES = "REFERENCES"
39
+ CONFIGURES = "CONFIGURES"
40
+ TESTS = "TESTS"
41
+
42
+
43
+ class ConfidenceClass(StrEnum):
44
+ """Classe de confiança da evidência — não é probabilidade (Blueprint §5)."""
45
+
46
+ RESOLVED = "resolved"
47
+ SYNTACTIC = "syntactic"
48
+ HEURISTIC = "heuristic"
49
+ INFERRED = "inferred"
50
+
51
+
52
+ class ResolutionStatus(StrEnum):
53
+ """Status de resolução de um alvo de relação (Blueprint §5)."""
54
+
55
+ RESOLVED = "resolved"
56
+ AMBIGUOUS = "ambiguous"
57
+ UNRESOLVED = "unresolved"
58
+ EXTERNAL = "external"
59
+
60
+
61
+ class SnapshotStatus(StrEnum):
62
+ """Estados do ciclo de vida de um snapshot (Blueprint §5)."""
63
+
64
+ BUILDING = "BUILDING"
65
+ READY = "READY"
66
+ FAILED = "FAILED"
67
+ STALE = "STALE"
@@ -0,0 +1,175 @@
1
+ """Modelos de domínio do Code Atlas (Blueprint §5).
2
+
3
+ Dataclasses stdlib, imutáveis onde possível. Campos de enum aceitam o valor
4
+ string ou o enum e são validados em ``__post_init__`` — valores fora do
5
+ vocabulário levantam ``ValueError``. ``id=None`` significa "a persistir".
6
+
7
+ Identidade lógica (``LogicalSymbol``, ``SourceFile``) é separada da
8
+ ocorrência por snapshot (``SymbolOccurrence``, ``file_inventory``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass, field
15
+
16
+ from code_atlas.contracts.enums import (
17
+ ConfidenceClass,
18
+ EdgeKind,
19
+ NodeKind,
20
+ ResolutionStatus,
21
+ SnapshotStatus,
22
+ )
23
+
24
+
25
+ def _coerce(enum_cls: type, value: object, field_name: str) -> object:
26
+ try:
27
+ return enum_cls(value)
28
+ except ValueError as exc:
29
+ raise ValueError(f"invalid {field_name}: {value!r}") from exc
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Repository:
34
+ """Repositório indexado; `id` independe do pathname local."""
35
+
36
+ root_handle: str
37
+ id: int | None = None
38
+ origin: str = ""
39
+ git_provider: str = ""
40
+ default_branch: str = ""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Snapshot:
45
+ """Ocorrência imutável de um estado do repositório."""
46
+
47
+ repository_id: int
48
+ worktree_digest: str
49
+ id: int | None = None
50
+ git_ref: str = ""
51
+ commit_sha: str | None = None
52
+ dirty: bool = False # lf-0021: conteúdo diverge do commit_sha
53
+ analyzer_versions: Mapping[str, str] = field(default_factory=dict)
54
+ parent_snapshot_id: int | None = None
55
+ status: SnapshotStatus = SnapshotStatus.BUILDING
56
+ created_at: str = ""
57
+
58
+ def __post_init__(self) -> None:
59
+ object.__setattr__(
60
+ self, "status", _coerce(SnapshotStatus, self.status, "status")
61
+ )
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class LogicalSymbol:
66
+ """Símbolo com identidade lógica estável por repositório."""
67
+
68
+ repository_id: int
69
+ language: str
70
+ qualified_name: str
71
+ kind: NodeKind
72
+ id: int | None = None
73
+
74
+ def __post_init__(self) -> None:
75
+ object.__setattr__(self, "kind", _coerce(NodeKind, self.kind, "kind"))
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class DomainSourceFile:
80
+ """Arquivo-fonte com identidade por (repo, relative_path)."""
81
+
82
+ repository_id: int
83
+ relative_path: str
84
+ language: str
85
+ id: int | None = None
86
+ sha256: str | None = None
87
+ size_bytes: int = 0
88
+ ignored: bool = False
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class SymbolOccurrence:
93
+ """Ocorrência de um símbolo num snapshot (span/hash são da ocorrência)."""
94
+
95
+ snapshot_id: int
96
+ logical_symbol_id: int
97
+ source_file_id: int
98
+ id: int | None = None
99
+ span_start: int | None = None
100
+ span_end: int | None = None
101
+ line: int | None = None
102
+ col: int | None = None
103
+ content_hash: str | None = None
104
+ parse_status: str = "parsed"
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class Relation:
109
+ """Aresta tipada com proveniência obrigatória (Blueprint ADR-012)."""
110
+
111
+ snapshot_id: int
112
+ from_id: int
113
+ relation_type: EdgeKind
114
+ resolver: str
115
+ confidence_class: ConfidenceClass
116
+ resolution_status: ResolutionStatus
117
+ id: int | None = None
118
+ to_id: int | None = None
119
+ to_unresolved: str | None = None
120
+ evidence_ids: tuple[int, ...] = ()
121
+
122
+ def __post_init__(self) -> None:
123
+ object.__setattr__(
124
+ self, "relation_type", _coerce(EdgeKind, self.relation_type, "relation_type")
125
+ )
126
+ object.__setattr__(
127
+ self,
128
+ "confidence_class",
129
+ _coerce(ConfidenceClass, self.confidence_class, "confidence_class"),
130
+ )
131
+ object.__setattr__(
132
+ self,
133
+ "resolution_status",
134
+ _coerce(ResolutionStatus, self.resolution_status, "resolution_status"),
135
+ )
136
+ if self.to_id is None and not self.to_unresolved:
137
+ raise ValueError("relation requires to_id or to_unresolved target")
138
+ if not self.resolver:
139
+ raise ValueError("relation requires resolver provenance")
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class Evidence:
144
+ """Trecho observável que sustenta um fato (extrator + versão + hash)."""
145
+
146
+ snapshot_id: int
147
+ source_file_id: int
148
+ path: str
149
+ content_hash: str
150
+ extractor: str
151
+ extractor_version: str
152
+ id: int | None = None
153
+ span: str | None = None
154
+ notes: str = ""
155
+
156
+ def __post_init__(self) -> None:
157
+ if not self.content_hash:
158
+ raise ValueError("evidence requires content_hash")
159
+ if not self.extractor or not self.extractor_version:
160
+ raise ValueError("evidence requires extractor + extractor_version")
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class IndexJob:
165
+ """Registro de uma tentativa de indexação (diagnóstico)."""
166
+
167
+ repository_id: int
168
+ status: str # running | success | failed | noop
169
+ id: int | None = None
170
+ snapshot_id: int | None = None
171
+ started_at: str = ""
172
+ finished_at: str = ""
173
+ counts: Mapping[str, int] = field(default_factory=dict)
174
+ failures: tuple[str, ...] = ()
175
+ manifest_hash: str = ""
@@ -0,0 +1,70 @@
1
+ """Ports hexagonais do domínio (Blueprint §3).
2
+
3
+ Interfaces ``Protocol`` — apenas assinaturas. Implementações reais do
4
+ Cycle 1: ``RepositoryScanner`` (discovery.scan), ``GraphStore`` parcial
5
+ (storage), ``IndexCoordinator`` (indexing). As demais são contrato para
6
+ ciclos futuros e não têm implementação placebo.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+ from pathlib import Path
13
+ from typing import Protocol
14
+
15
+ from code_atlas.contracts.enums import ResolutionStatus
16
+ from code_atlas.contracts.models import Snapshot, SymbolOccurrence
17
+ from code_atlas.discovery.models import ScanOptions, ScanResult
18
+
19
+
20
+ class RepositoryScanner(Protocol):
21
+ """Produz o inventário de arquivos de uma raiz (implementado: discovery)."""
22
+
23
+ def scan(
24
+ self, root: Path, options: ScanOptions | None = None
25
+ ) -> ScanResult: ...
26
+
27
+
28
+ class ParserAdapter(Protocol):
29
+ """Adaptador AST (Tree-sitter, Cycle 2) com capability matrix declarada."""
30
+
31
+ @property
32
+ def name(self) -> str: ...
33
+
34
+ @property
35
+ def version(self) -> str: ...
36
+
37
+ def languages(self) -> frozenset[str]: ...
38
+
39
+ def capabilities(self) -> frozenset[str]:
40
+ """Ex.: ``symbols``, ``imports``, ``calls``, ``docstrings``."""
41
+ ...
42
+
43
+
44
+ class SymbolResolver(Protocol):
45
+ """Resolve ocorrências para alvos canônicos (SCIP/LSP, Cycle 3)."""
46
+
47
+ @property
48
+ def name(self) -> str: ...
49
+
50
+ def resolve(self, occurrence: SymbolOccurrence) -> ResolutionStatus: ...
51
+
52
+
53
+ class GraphStore(Protocol):
54
+ """Persistência do grafo tipado (implementado parcialmente: storage)."""
55
+
56
+ def latest_ready_snapshot(self, repository_id: int) -> Snapshot | None: ...
57
+
58
+ def snapshot_by_id(self, snapshot_id: int) -> Snapshot | None: ...
59
+
60
+
61
+ class SearchPort(Protocol):
62
+ """Busca lexical/estrutural (FTS5, Cycle 4)."""
63
+
64
+ def search(self, query: str, limit: int = 20) -> Sequence[object]: ...
65
+
66
+
67
+ class IndexCoordinator(Protocol):
68
+ """Orquestra scan → snapshot → publicação atômica (implementado: indexing)."""
69
+
70
+ def index(self, root: Path) -> Snapshot: ...
@@ -0,0 +1,25 @@
1
+ """Discovery Git-aware: inventário de arquivos, fingerprints e manifest."""
2
+
3
+ from code_atlas.discovery.models import (
4
+ GitInfo,
5
+ ScanMode,
6
+ ScanOptions,
7
+ ScanResult,
8
+ ScanWarning,
9
+ SourceFile,
10
+ WarningKind,
11
+ )
12
+ from code_atlas.discovery.scanner import probe_git, scan
13
+
14
+ __all__ = [
15
+ "GitInfo",
16
+ "ScanMode",
17
+ "ScanOptions",
18
+ "ScanResult",
19
+ "ScanWarning",
20
+ "SourceFile",
21
+ "WarningKind",
22
+ "probe_git",
23
+ "scan",
24
+ ]
25
+
@@ -0,0 +1,119 @@
1
+ """Parser mínimo de `.gitignore` (fallback sem git).
2
+
3
+ Best-effort: suporta `*`, `?`, `[...]`, `**`, âncora `/`, negação `!` e
4
+ padrões de diretório com `/` final. Escopo é o diretório do `.gitignore`.
5
+ Semântica: a última regra que casa decide (ordem do arquivo, raiz → folha).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+
15
+ def _translate(pattern: str) -> str:
16
+ """Converte um glob gitignore em regex sobre path relativo POSIX."""
17
+ out = []
18
+ i = 0
19
+ while i < len(pattern):
20
+ c = pattern[i]
21
+ if c == "*":
22
+ if pattern[i : i + 2] == "**":
23
+ if pattern[i : i + 3] == "**/":
24
+ out.append("(?:.*/)?")
25
+ i += 3
26
+ continue
27
+ out.append(".*")
28
+ i += 2
29
+ continue
30
+ out.append("[^/]*")
31
+ elif c == "?":
32
+ out.append("[^/]")
33
+ elif c == "[":
34
+ j = pattern.find("]", i + 1)
35
+ if j == -1:
36
+ out.append(re.escape(c))
37
+ else:
38
+ out.append(pattern[i : j + 1])
39
+ i = j
40
+ else:
41
+ out.append(re.escape(c))
42
+ i += 1
43
+ return "".join(out)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class _Rule:
48
+ regex: re.Pattern[str]
49
+ negate: bool
50
+ dir_only: bool
51
+
52
+
53
+ class GitIgnoreScope:
54
+ """Regras de um único `.gitignore`, aplicáveis a paths relativos a seu dir."""
55
+
56
+ def __init__(self, base: str, rules: list[_Rule]) -> None:
57
+ self.base = base # dir relativo POSIX onde o .gitignore vive ("" = raiz)
58
+ self.rules = rules
59
+
60
+ @classmethod
61
+ def parse(cls, base: str, text: str) -> GitIgnoreScope:
62
+ rules: list[_Rule] = []
63
+ for raw in text.splitlines():
64
+ line = raw.strip()
65
+ if not line or line.startswith("#"):
66
+ continue
67
+ negate = line.startswith("!")
68
+ if negate:
69
+ line = line[1:]
70
+ dir_only = line.endswith("/")
71
+ line = line.rstrip("/")
72
+ anchored = line.startswith("/")
73
+ line = line.lstrip("/")
74
+ if not line:
75
+ continue
76
+ if "/" not in line and not anchored:
77
+ # Sem barra: casa basename em qualquer profundidade do escopo.
78
+ regex = re.compile(rf"(?:^|/){_translate(line)}$")
79
+ else:
80
+ regex = re.compile(rf"^{_translate(line)}$")
81
+ rules.append(_Rule(regex=regex, negate=negate, dir_only=dir_only))
82
+ return cls(base=base, rules=rules)
83
+
84
+ def matches(self, path: str, is_dir: bool) -> bool | None:
85
+ """True/False se decidido por este escopo; None se nenhuma regra casa."""
86
+ rel = path[len(self.base) :].lstrip("/") if self.base else path
87
+ if not rel:
88
+ return None
89
+ decision: bool | None = None
90
+ for rule in self.rules:
91
+ if rule.dir_only and not is_dir:
92
+ continue
93
+ if rule.regex.match(rel):
94
+ decision = not rule.negate # último match vence
95
+ return decision
96
+
97
+
98
+ class GitIgnoreStack:
99
+ """Pilha de escopos `.gitignore`; a decisão mais profunda e tardia vence."""
100
+
101
+ def __init__(self) -> None:
102
+ self._scopes: list[GitIgnoreScope] = []
103
+
104
+ def add_file(self, base_dir: str, path: Path) -> None:
105
+ try:
106
+ text = path.read_text(encoding="utf-8", errors="replace")
107
+ except OSError:
108
+ return
109
+ self._scopes.append(GitIgnoreScope.parse(base_dir, text))
110
+
111
+ def is_ignored(self, path: str, is_dir: bool) -> bool:
112
+ """True se ignorado; o escopo mais profundo que decide vence."""
113
+ for scope in reversed(self._scopes):
114
+ if scope.base and not path.startswith(scope.base + "/"):
115
+ continue
116
+ decision = scope.matches(path, is_dir)
117
+ if decision is not None:
118
+ return decision
119
+ return False
@@ -0,0 +1,52 @@
1
+ """Identidade lógica do repositório (lf-0021).
2
+
3
+ ``repository_id`` é derivado de identidade **lógica**, nunca só do path:
4
+
5
+ 1. ``git-remote:<remote.origin.url>`` — repositório com remote;
6
+ 2. ``git-root:<sha do commit raiz>`` — repo git sem remote;
7
+ 3. ``path:<root resolvido>`` — último recurso (sem git).
8
+
9
+ Realocar o diretório físico mantém a identidade (``root_handle`` passa a
10
+ ser campo de diagnóstico atualizado a cada index). Dois checkouts do
11
+ mesmo remote **compartilham** identidade — snapshots de ambos vivem sob
12
+ o mesmo repo e se distinguem por ``worktree_digest``/revisão.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ from code_atlas.discovery.models import GitInfo
20
+ from code_atlas.discovery.scanner import probe_git
21
+
22
+
23
+ def identity_key(git: GitInfo, root: Path) -> str:
24
+ """Chave de identidade estável do repositório.
25
+
26
+ lf-0058: raiz **abaixo** do toplevel git recebe sufixo
27
+ ``@<caminho relativo>`` — dois projetos num mesmo monorepo não
28
+ colidem mais (antes compartilhavam o remote → mesmo repo).
29
+ Raiz == toplevel mantém o formato legado (retrocompatível);
30
+ realocar o diretório preserva identidade pois o relpath é estável.
31
+ """
32
+ base = ""
33
+ if git.remote_url:
34
+ base = f"git-remote:{git.remote_url}"
35
+ elif git.root_commit:
36
+ base = f"git-root:{git.root_commit}"
37
+ else:
38
+ return f"path:{Path(root).resolve()}"
39
+ if git.toplevel:
40
+ try:
41
+ rel = Path(root).resolve().relative_to(
42
+ Path(git.toplevel).resolve())
43
+ except ValueError:
44
+ rel = None
45
+ if rel is not None and str(rel) not in ("", "."):
46
+ return f"{base}@{rel.as_posix()}"
47
+ return base
48
+
49
+
50
+ def identity_key_for_root(root: Path) -> str:
51
+ """Identidade sem scan completo — usado por ``init`` (probe apenas)."""
52
+ return identity_key(probe_git(Path(root)), Path(root))