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,204 @@
1
+ """Modelos do resultado de scan (Cycle 1).
2
+
3
+ Sem persistência: o scanner retorna estruturas de domínio; lf-0003 as grava.
4
+ O manifest é determinístico — nenhum timestamp nem caminho absoluto no corpo.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+
14
+
15
+ class ScanMode(StrEnum):
16
+ """Como o scan operou."""
17
+
18
+ GIT = "git" # git binary + repo presentes
19
+ GIT_REPO_NO_BINARY = "git_repo_no_binary" # .git existe mas git ausente
20
+ NO_GIT = "no_git" # diretório comum
21
+
22
+
23
+ class WarningKind(StrEnum):
24
+ """Categoria tipada de warning do scan."""
25
+
26
+ SYMLINK_ESCAPE = "symlink_escape"
27
+ LIMIT_FILES = "limit_files"
28
+ LIMIT_DEPTH = "limit_depth"
29
+ LIMIT_SIZE = "limit_size"
30
+ TIMEOUT = "timeout"
31
+ READ_ERROR = "read_error"
32
+ GIT_ERROR = "git_error"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ScanWarning:
37
+ """Warning tipado; `path` é relativo POSIX quando aplicável."""
38
+
39
+ kind: WarningKind
40
+ message: str
41
+ path: str = ""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SourceFile:
46
+ """Registro de um arquivo descoberto.
47
+
48
+ `sha256` é None quando não hasheado (ignorado, oversize ou erro de leitura).
49
+ """
50
+
51
+ path: str # relativo POSIX, canônico
52
+ language: str
53
+ size_bytes: int
54
+ sha256: str | None
55
+ ignored: bool = False
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class GitInfo:
60
+ """Estado git detectado na raiz."""
61
+
62
+ available: bool
63
+ is_repo: bool
64
+ branch: str = ""
65
+ head_sha: str = ""
66
+ dirty: bool = False
67
+ is_worktree: bool = False
68
+ remote_url: str = "" # remote.origin.url — identidade preferida (lf-0021)
69
+ root_commit: str = "" # 1º commit (max-parents=0) — identidade fallback
70
+ toplevel: str = "" # rev-parse --show-toplevel (lf-0058: escopo sub-root)
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class ScanResult:
75
+ """Resultado completo de um scan de diretório."""
76
+
77
+ files: tuple[SourceFile, ...]
78
+ git: GitInfo
79
+ mode: ScanMode
80
+ warnings: tuple[ScanWarning, ...] = ()
81
+ secret_count: int = 0
82
+ partial: bool = False
83
+ truncated: bool = False
84
+ elapsed_s: float = 0.0 # metadado de execução — fora do manifest
85
+ hash_ms: int = 0 # tempo em _hash_file (lf-0031) — fora do manifest
86
+
87
+ def manifest_bytes(self) -> bytes:
88
+ """Serialização canônica e determinística do inventário.
89
+
90
+ Não contém timestamps, caminhos absolutos nem metadados de execução:
91
+ dois scans da mesma árvore produzem bytes idênticos.
92
+ """
93
+ payload = {
94
+ "mode": str(self.mode),
95
+ "git": {
96
+ "available": self.git.available,
97
+ "is_repo": self.git.is_repo,
98
+ "branch": self.git.branch,
99
+ "head_sha": self.git.head_sha,
100
+ "dirty": self.git.dirty,
101
+ "is_worktree": self.git.is_worktree,
102
+ },
103
+ "files": [
104
+ {
105
+ "path": f.path,
106
+ "language": f.language,
107
+ "size_bytes": f.size_bytes,
108
+ "sha256": f.sha256,
109
+ "ignored": f.ignored,
110
+ }
111
+ for f in sorted(self.files, key=lambda x: x.path)
112
+ ],
113
+ "secret_count": self.secret_count,
114
+ "partial": self.partial,
115
+ "truncated": self.truncated,
116
+ "warnings": [
117
+ {"kind": str(w.kind), "message": w.message, "path": w.path}
118
+ for w in sorted(self.warnings, key=lambda x: (x.path, str(x.kind)))
119
+ ],
120
+ }
121
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
122
+
123
+ def manifest_sha256(self) -> str:
124
+ """Fingerprint do manifest canônico."""
125
+ return hashlib.sha256(self.manifest_bytes()).hexdigest()
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class ScanOptions:
130
+ """Limites e comportamento do scanner."""
131
+
132
+ max_files: int = 100_000
133
+ max_file_size: int = 10 * 1024 * 1024 # 10 MiB
134
+ max_depth: int = 40
135
+ timeout_s: float | None = None
136
+ include_untracked: bool = True
137
+ hash_chunk_size: int = 64 * 1024
138
+
139
+
140
+ # Extensão → linguagem preliminar (Blueprint §4.1). Mapa intencionalmente curto;
141
+ # Tree-sitter (Cycle 2) assume a classificação definitiva.
142
+ LANGUAGE_BY_EXT: dict[str, str] = {
143
+ ".py": "python", ".pyi": "python", ".js": "javascript", ".mjs": "javascript",
144
+ ".cjs": "javascript", ".jsx": "javascript", ".ts": "typescript",
145
+ ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
146
+ ".go": "go", ".rs": "rust", ".java": "java", ".kt": "kotlin",
147
+ ".kts": "kotlin", ".c": "c", ".h": "c", ".cpp": "cpp", ".cxx": "cpp",
148
+ ".hpp": "cpp", ".cs": "csharp", ".rb": "ruby", ".php": "php",
149
+ ".swift": "swift", ".m": "objective-c", ".scala": "scala", ".sh": "shell",
150
+ ".bash": "shell", ".zsh": "shell", ".ps1": "powershell", ".sql": "sql",
151
+ ".html": "html", ".htm": "html", ".css": "css", ".scss": "scss",
152
+ ".md": "markdown", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml",
153
+ ".json": "json", ".xml": "xml", ".r": "r", ".lua": "lua", ".pl": "perl",
154
+ ".ex": "elixir", ".exs": "elixir", ".erl": "erlang", ".hs": "haskell",
155
+ ".clj": "clojure", ".dart": "dart", ".vue": "vue", ".svelte": "svelte",
156
+ }
157
+
158
+ # Diretórios/arquivos podados por padrão (Blueprint §4.1) — nunca inventariados.
159
+ ATLAS_DEFAULT_IGNORES: tuple[str, ...] = (
160
+ ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
161
+ ".tox", ".nox", "dist", "build", ".idea", ".vscode", ".mypy_cache",
162
+ ".pytest_cache", ".ruff_cache", ".next", ".nuxt", "target", "coverage",
163
+ ".eggs", "*.egg-info", ".factory-cache",
164
+ )
165
+
166
+ # Padrões de segredo (Blueprint ADR-014): excluídos de hash e inventário,
167
+ # contabilizados apenas em `secret_count`.
168
+ SECRET_PATTERNS: tuple[str, ...] = (
169
+ ".env", ".env.*", "*.env", ".envrc", ".envrc.*",
170
+ "*.pem", "*.key", "*.p12", "*.pfx", "*.keystore",
171
+ "*.jks", "id_rsa", "id_rsa.*", "id_dsa", "id_dsa.*", "id_ed25519",
172
+ "id_ed25519.*", "credentials", "credentials.*", "*.credentials",
173
+ ".netrc", ".npmrc", ".pypirc", "*.secret", "secrets.*",
174
+ )
175
+
176
+
177
+ def language_for(path: str) -> str:
178
+ """Linguagem preliminar pela extensão; `unknown`/`other` quando ausente."""
179
+ lower = path.lower()
180
+ dot = lower.rfind(".")
181
+ if dot < 0:
182
+ return "other"
183
+ return LANGUAGE_BY_EXT.get(lower[dot:], "other")
184
+
185
+
186
+ def _basename_match(name: str, patterns: tuple[str, ...]) -> bool:
187
+ import fnmatch
188
+
189
+ return any(fnmatch.fnmatchcase(name, p) for p in patterns)
190
+
191
+
192
+ def is_secret(path: str) -> bool:
193
+ """True se o basename do path casa padrões de segredo."""
194
+ return _basename_match(path.rsplit("/", 1)[-1], SECRET_PATTERNS)
195
+
196
+
197
+ def is_default_ignored(path: str) -> bool:
198
+ """True se qualquer segmento/basename casa os ignores padrão do Atlas."""
199
+ parts = path.split("/")
200
+ name = parts[-1]
201
+ if _basename_match(name, ATLAS_DEFAULT_IGNORES):
202
+ return True
203
+ vcs_dirs = {".git", ".hg", ".svn", "node_modules", "__pycache__"}
204
+ return any(part in vcs_dirs for part in parts[:-1])
@@ -0,0 +1,495 @@
1
+ """Scanner Git-aware de inventário (Blueprint §4.1 / ADR-014).
2
+
3
+ Perímetro de segurança do Code Atlas: decide o que entra no inventário.
4
+ Nunca executa, importa ou faz shell-out para código do repositório varrido —
5
+ apenas invocações do binário `git` contra a raiz são permitidas (argv, sem
6
+ shell). Nada é escrito dentro do diretório varrido.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import hashlib
13
+ import os
14
+ import stat
15
+ import time
16
+ from collections.abc import Iterator
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from code_atlas import gitbin
21
+ from code_atlas.discovery.gitignore import GitIgnoreStack
22
+ from code_atlas.discovery.models import (
23
+ GitInfo,
24
+ ScanMode,
25
+ ScanOptions,
26
+ ScanResult,
27
+ ScanWarning,
28
+ SourceFile,
29
+ WarningKind,
30
+ is_default_ignored,
31
+ is_secret,
32
+ language_for,
33
+ )
34
+
35
+
36
+ def _git_lines(root: Path, args: list[str]) -> list[str]:
37
+ """`git` com saída separada por NUL → paths relativos POSIX."""
38
+ out = gitbin.run_git(args, cwd=root)
39
+ if out.returncode != 0:
40
+ return []
41
+ return [p for p in out.stdout.decode("utf-8", "replace").split("\0") if p]
42
+
43
+
44
+ def probe_git(root: Path, git_available: bool | None = None) -> GitInfo:
45
+ """Detecta estado git da raiz: repo, branch, HEAD, dirty, worktree.
46
+
47
+ `git_available=None` usa `shutil.which`; testes podem injetar False para
48
+ simular git ausente.
49
+ """
50
+ if git_available is None:
51
+ git_available = gitbin.git_binary() is not None
52
+ has_dot_git = (root / ".git").exists()
53
+ if not git_available:
54
+ return GitInfo(available=False, is_repo=has_dot_git)
55
+ inside = gitbin.run_git(["rev-parse", "--is-inside-work-tree"], cwd=root)
56
+ if inside.returncode != 0 or inside.stdout.strip() != b"true":
57
+ return GitInfo(available=True, is_repo=False)
58
+
59
+ def _out(args: list[str]) -> str:
60
+ res = gitbin.run_git(args, cwd=root)
61
+ return res.stdout.decode("utf-8", "replace").strip() if res.returncode == 0 else ""
62
+
63
+ branch = _out(["symbolic-ref", "--quiet", "--short", "HEAD"])
64
+ head = _out(["rev-parse", "HEAD"])
65
+ dirty = bool(_git_lines(root, ["status", "--porcelain", "-z"]))
66
+ git_dir = _out(["rev-parse", "--git-dir"])
67
+ common = _out(["rev-parse", "--git-common-dir"])
68
+ is_worktree = bool(git_dir and common and git_dir != common) or not (root / ".git").is_dir()
69
+ # lf-0021: identidade lógica — remote URL preferida; commit raiz como
70
+ # fallback (repo sem remote). ``rev-list --max-parents=0`` pode ter
71
+ # múltiplas raízes em históricos merged — ordenado para estabilidade.
72
+ remote_url = _out(["config", "--get", "remote.origin.url"])
73
+ roots = sorted(
74
+ x for x in _out(["rev-list", "--max-parents=0", "HEAD"]).splitlines() if x
75
+ )
76
+ return GitInfo(
77
+ available=True,
78
+ is_repo=True,
79
+ branch=branch,
80
+ head_sha=head,
81
+ dirty=dirty,
82
+ is_worktree=is_worktree,
83
+ remote_url=remote_url,
84
+ root_commit=roots[0] if roots else "",
85
+ toplevel=_out(["rev-parse", "--show-toplevel"]),
86
+ )
87
+
88
+
89
+ def _file_open_flags() -> int:
90
+ """Flags de abertura defensiva; ausentes na plataforma viram no-op."""
91
+ flags = os.O_RDONLY
92
+ for name in ("O_NOFOLLOW", "O_CLOEXEC", "O_NONBLOCK", "O_BINARY"):
93
+ flags |= getattr(os, name, 0)
94
+ return flags
95
+
96
+
97
+ def _open_verified(path: Path, root_real: Path) -> int | None:
98
+ """Abre `path` de forma defensiva; fd ou None em escape/erro.
99
+
100
+ Resolve o caminho, reconfere contenção na raiz e abre via ``os.open``
101
+ com ``O_NOFOLLOW`` — se o último componente virar symlink entre
102
+ resolve e open, a abertura falha em vez de seguir o link.
103
+ ``O_NONBLOCK`` evita bloqueio em FIFOs; ``fstat`` garante regular.
104
+ """
105
+ try:
106
+ resolved = path.resolve(strict=True)
107
+ if not resolved.is_relative_to(root_real):
108
+ return None
109
+ fd = os.open(resolved, _file_open_flags())
110
+ except OSError:
111
+ return None
112
+ try:
113
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
114
+ os.close(fd)
115
+ return None
116
+ except OSError:
117
+ with contextlib.suppress(OSError):
118
+ os.close(fd)
119
+ return None
120
+ # O_NONBLOCK só serve para a janela open→fstat (não travar em FIFO);
121
+ # em arquivo regular ele torna a leitura racy — EAGAIN aparece sob
122
+ # pressão de scheduler e vira sha256=None intermitente. Limpa antes
123
+ # de devolver (no-op onde set_blocking não existe).
124
+ with contextlib.suppress(OSError, AttributeError):
125
+ os.set_blocking(fd, True)
126
+ return fd
127
+
128
+
129
+ def artifact_path_confined(rel_path: str, root_real: Path) -> Path | None:
130
+ """Resolve ``rel_path`` sob ``root_real`` com contenção garantida.
131
+
132
+ ``None`` quando o path é absoluto, contém ``..`` ou resolve fora da
133
+ raiz (inclusive symlink trocado entre scan e leitura). Adapters usam
134
+ isto antes de abrir o artefato declarado em ``request.artifacts`` —
135
+ o path do request é tão hostil quanto o conteúdo (lf-c8 F7).
136
+ """
137
+ p = Path(rel_path)
138
+ if p.is_absolute() or ".." in p.parts:
139
+ return None
140
+ try:
141
+ base = Path(root_real).resolve()
142
+ resolved = (base / p).resolve(strict=False)
143
+ except (OSError, RuntimeError):
144
+ return None
145
+ if not resolved.is_relative_to(base):
146
+ return None
147
+ return resolved
148
+
149
+
150
+ def read_file_verified(path: Path, root_real: Path) -> bytes | None:
151
+ """Lê bytes com a mesma disciplina de ``_hash_file`` (lf-0015).
152
+
153
+ Usado pelo coordinator para garantir que o conteúdo parseado veio do
154
+ mesmo caminho verificado — nunca de symlink fora da raiz.
155
+ """
156
+ fd = _open_verified(path, root_real)
157
+ if fd is None:
158
+ return None
159
+ try:
160
+ with os.fdopen(fd, "rb") as fh:
161
+ fd = -1 # posse transferida para fh — finally não fecha
162
+ return fh.read()
163
+ except OSError:
164
+ return None
165
+ finally:
166
+ if fd >= 0:
167
+ with contextlib.suppress(OSError):
168
+ os.close(fd)
169
+
170
+
171
+ def _hash_file(path: Path, chunk_size: int, root_real: Path) -> str | None:
172
+ """SHA-256 por streaming; None em erro de leitura ou escape.
173
+
174
+ Mitigação TOCTOU proporcional (Phase 0 / P1.1): resolve o caminho,
175
+ reconfere contenção na raiz e abre o alvo via ``os.open`` com
176
+ ``O_NOFOLLOW`` — se o último componente virar symlink entre resolve e
177
+ open, a abertura falha em vez de seguir o link. ``O_NONBLOCK`` evita
178
+ bloqueio em FIFOs; ``fstat`` garante arquivo regular.
179
+ """
180
+ fd = _open_verified(path, root_real)
181
+ if fd is None:
182
+ return None
183
+ try:
184
+ digest = hashlib.sha256()
185
+ with os.fdopen(fd, "rb") as fh:
186
+ fd = -1 # posse transferida para fh — finally não fecha
187
+ while chunk := fh.read(chunk_size):
188
+ digest.update(chunk)
189
+ return digest.hexdigest()
190
+ except OSError:
191
+ return None
192
+ finally:
193
+ if fd >= 0:
194
+ with contextlib.suppress(OSError):
195
+ os.close(fd)
196
+
197
+
198
+ def _rel_posix(root: Path, path: Path) -> str:
199
+ return path.relative_to(root).as_posix()
200
+
201
+
202
+ def _depth(rel: str) -> int:
203
+ return len(rel.split("/"))
204
+
205
+
206
+ class _Budget:
207
+ """Rastreia limites, warnings e deadline do scan."""
208
+
209
+ def __init__(self, options: ScanOptions) -> None:
210
+ self.options = options
211
+ self.warnings: list[ScanWarning] = []
212
+ self.truncated = False
213
+ self.timed_out = False
214
+ self.deadline = (
215
+ time.monotonic() + options.timeout_s if options.timeout_s is not None else None
216
+ )
217
+ self._depth_warned: set[str] = set()
218
+
219
+ def check_timeout(self) -> bool:
220
+ if self.deadline is not None and time.monotonic() > self.deadline:
221
+ if not self.timed_out:
222
+ self.warnings.append(
223
+ ScanWarning(WarningKind.TIMEOUT, f"scan timeout {self.options.timeout_s}s")
224
+ )
225
+ self.timed_out = True
226
+ return True
227
+ return False
228
+
229
+ def note_depth_breach(self, rel: str) -> None:
230
+ parent = rel.rsplit("/", 1)[0]
231
+ if parent not in self._depth_warned:
232
+ self._depth_warned.add(parent)
233
+ self.warnings.append(
234
+ ScanWarning(
235
+ WarningKind.LIMIT_DEPTH,
236
+ f"depth limit {self.options.max_depth} exceeded",
237
+ parent,
238
+ )
239
+ )
240
+
241
+
242
+ @dataclass
243
+ class _Accumulator:
244
+ files: list[SourceFile]
245
+ secret_count: int = 0
246
+ count: int = 0
247
+ hash_ns: int = 0 # lf-0031: tempo acumulado em _hash_file
248
+
249
+
250
+ def _classify_and_hash(
251
+ abs_path: Path,
252
+ rel: str,
253
+ ignored: bool,
254
+ options: ScanOptions,
255
+ acc: _Accumulator,
256
+ budget: _Budget,
257
+ root_real: Path,
258
+ ) -> None:
259
+ """Monta o registro SourceFile aplicando segredos, limites e hash."""
260
+ if is_secret(rel):
261
+ acc.secret_count += 1
262
+ return
263
+ if is_default_ignored(rel):
264
+ return
265
+ if _depth(rel) > options.max_depth:
266
+ budget.note_depth_breach(rel)
267
+ return
268
+ if acc.count >= options.max_files:
269
+ if not budget.truncated:
270
+ budget.truncated = True
271
+ budget.warnings.append(
272
+ ScanWarning(
273
+ WarningKind.LIMIT_FILES,
274
+ f"file count limit {options.max_files} reached",
275
+ rel,
276
+ )
277
+ )
278
+ return
279
+ try:
280
+ size = abs_path.stat().st_size
281
+ except OSError as exc:
282
+ _on_stat_failure(abs_path, rel, exc, acc, budget)
283
+ return
284
+ acc.count += 1
285
+ if ignored:
286
+ return # ignorados são excluídos do inventário (gitignore / fallback)
287
+ if size > options.max_file_size:
288
+ acc.files.append(
289
+ SourceFile(path=rel, language=language_for(rel), size_bytes=size, sha256=None)
290
+ )
291
+ return
292
+ _h0 = time.monotonic_ns()
293
+ digest = _hash_file(abs_path, options.hash_chunk_size, root_real)
294
+ acc.hash_ns += time.monotonic_ns() - _h0
295
+ if digest is None:
296
+ budget.warnings.append(
297
+ ScanWarning(WarningKind.READ_ERROR, "unreadable or vanished during scan", rel)
298
+ )
299
+ acc.files.append(
300
+ SourceFile(path=rel, language=language_for(rel), size_bytes=size, sha256=digest)
301
+ )
302
+
303
+
304
+ def _symlink_escapes(path: Path, root_real: Path) -> bool:
305
+ """True se `path` é symlink cujo alvo resolve fora de `root_real`."""
306
+ try:
307
+ return not path.resolve().is_relative_to(root_real)
308
+ except (OSError, RuntimeError):
309
+ # RuntimeError: ciclos de symlink (``a -> b -> a``) — repo hostil
310
+ # não deve derrubar o scan; tratado como escape.
311
+ return True
312
+
313
+
314
+ def _filter_dirs(
315
+ d: Path, rel_dir: str, dirnames: list[str], stack: GitIgnoreStack,
316
+ root_real: Path, options: ScanOptions, budget: _Budget,
317
+ ) -> list[str]:
318
+ """Prune de diretórios: symlinks de dir, ignores e profundidade."""
319
+ kept = []
320
+ for name in dirnames:
321
+ sub = d / name
322
+ rel = f"{rel_dir}/{name}" if rel_dir else name
323
+ if sub.is_symlink():
324
+ if _symlink_escapes(sub, root_real):
325
+ budget.warnings.append(
326
+ ScanWarning(WarningKind.SYMLINK_ESCAPE, "dir symlink escapes root", rel)
327
+ )
328
+ continue # nunca desce em symlink de diretório
329
+ if is_default_ignored(rel) or stack.is_ignored(rel, is_dir=True):
330
+ continue
331
+ if _depth(rel) > options.max_depth:
332
+ budget.note_depth_breach(rel + "/")
333
+ continue
334
+ kept.append(name)
335
+ return sorted(kept)
336
+
337
+
338
+ def _walk_paths(
339
+ root: Path, root_real: Path, options: ScanOptions, budget: _Budget
340
+ ) -> Iterator[tuple[Path, str, bool]]:
341
+ """Gera (abs_path, rel_posix, ignored) via os.walk + ignores best-effort."""
342
+ stack = GitIgnoreStack()
343
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
344
+ if budget.check_timeout():
345
+ return
346
+ d = Path(dirpath)
347
+ rel_dir = _rel_posix(root, d) if d != root else ""
348
+
349
+ gi = d / ".gitignore"
350
+ if gi.is_file() and not gi.is_symlink():
351
+ stack.add_file(rel_dir, gi)
352
+
353
+ dirnames[:] = _filter_dirs(d, rel_dir, dirnames, stack, root_real, options, budget)
354
+
355
+ for name in sorted(filenames):
356
+ f = d / name
357
+ rel = f"{rel_dir}/{name}" if rel_dir else name
358
+ if f.is_symlink() and _symlink_escapes(f, root_real):
359
+ budget.warnings.append(
360
+ ScanWarning(
361
+ WarningKind.SYMLINK_ESCAPE, "file symlink escapes root", rel
362
+ )
363
+ )
364
+ continue
365
+ # symlink quebrado/ausente não é descartado aqui — scan()
366
+ # decide: checa alias-de-segredo pelo readlink cru ANTES
367
+ # do exists() (Windows: alvo POSIX relativo pode falhar
368
+ # exists() mas ainda é alias de .env).
369
+ ignored = stack.is_ignored(rel, is_dir=False)
370
+ yield f, rel, ignored
371
+
372
+
373
+ def _git_paths(
374
+ root: Path, options: ScanOptions, budget: _Budget, git_info: GitInfo
375
+ ) -> Iterator[tuple[Path, str, bool]]:
376
+ """Gera (abs_path, rel_posix, ignored) via `git ls-files`."""
377
+ tracked = _git_lines(root, ["ls-files", "-z", "-c"])
378
+ if not tracked and not git_info.head_sha:
379
+ tracked = []
380
+ untracked = (
381
+ _git_lines(root, ["ls-files", "-z", "-o", "--exclude-standard"])
382
+ if options.include_untracked
383
+ else []
384
+ )
385
+ ignored = set(_git_lines(root, ["ls-files", "-z", "-o", "-i", "--exclude-standard"]))
386
+ for rel in sorted(tracked):
387
+ yield root / rel, rel, False
388
+ for rel in sorted(untracked):
389
+ if budget.check_timeout():
390
+ return
391
+ yield root / rel, rel, rel in ignored
392
+ for rel in sorted(ignored):
393
+ yield root / rel, rel, True
394
+
395
+
396
+ def _on_stat_failure(
397
+ abs_path: Path, rel: str, exc: OSError,
398
+ acc: _Accumulator, budget: _Budget,
399
+ ) -> None:
400
+ """Stat falhou — última chance de alias-de-segredo (Windows:
401
+ symlink com alvo relativo POSIX cujo stat falha mas o readlink
402
+ ainda revela o alvo ``.env``); senão READ_ERROR honesto."""
403
+ if _symlink_targets_secret(abs_path):
404
+ acc.secret_count += 1
405
+ return
406
+ budget.warnings.append(ScanWarning(WarningKind.READ_ERROR, str(exc), rel))
407
+
408
+
409
+ def _symlink_targets_secret(path: Path) -> bool:
410
+ """True se o alvo do symlink tem basename com padrão de segredo.
411
+
412
+ Testa o nome resolvido E o basename cru do ``readlink`` —
413
+ ``resolve()`` pode falhar (ou devolver o nome do próprio link) em
414
+ alvos relativos POSIX no Windows.
415
+ """
416
+ names: set[str] = set()
417
+ with contextlib.suppress(OSError, RuntimeError):
418
+ names.add(path.resolve().name)
419
+ with contextlib.suppress(OSError):
420
+ names.add(
421
+ os.readlink(path).rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
422
+ )
423
+ return any(is_secret(n) for n in names)
424
+
425
+
426
+ def scan(
427
+ root: Path, options: ScanOptions | None = None, git_available: bool | None = None
428
+ ) -> ScanResult:
429
+ """Varre `root` e retorna um `ScanResult` determinístico.
430
+
431
+ Ctrl-C ou timeout retorna resultado `partial=True` com o acumulado.
432
+ """
433
+ options = options or ScanOptions()
434
+ started = time.monotonic()
435
+ root = Path(root).resolve()
436
+ root_real = root # já resolvido
437
+ budget = _Budget(options)
438
+ acc = _Accumulator(files=[])
439
+
440
+ git_info = probe_git(root, git_available)
441
+ if git_info.available and git_info.is_repo:
442
+ mode = ScanMode.GIT
443
+ path_iter = _git_paths(root, options, budget, git_info)
444
+ else:
445
+ mode = (
446
+ ScanMode.GIT_REPO_NO_BINARY
447
+ if git_info.is_repo
448
+ else ScanMode.NO_GIT
449
+ )
450
+ path_iter = _walk_paths(root, root_real, options, budget)
451
+
452
+ partial = False
453
+ try:
454
+ for abs_path, rel, ignored in path_iter:
455
+ if budget.check_timeout():
456
+ partial = True
457
+ break
458
+ # modo git: symlinks rastreados nunca passaram pelo filtro do
459
+ # walk — checar escape aqui cobre os dois enumeradores.
460
+ if abs_path.is_symlink():
461
+ if _symlink_escapes(abs_path, root_real):
462
+ budget.warnings.append(
463
+ ScanWarning(
464
+ WarningKind.SYMLINK_ESCAPE, "symlink escapes root", rel
465
+ )
466
+ )
467
+ continue
468
+ # Alias para segredo: nome benigno apontando para arquivo
469
+ # com nome de segredo (``prod.tfvars -> .env``). Sem isso o
470
+ # conteúdo entraria em hash/inventário e chegaria aos
471
+ # adapters (lf-c8 F2).
472
+ if _symlink_targets_secret(abs_path):
473
+ acc.secret_count += 1
474
+ continue
475
+ if abs_path.is_dir() or not abs_path.exists():
476
+ continue
477
+ _classify_and_hash(abs_path, rel, ignored, options, acc, budget, root_real)
478
+ except KeyboardInterrupt:
479
+ partial = True
480
+ budget.warnings.append(
481
+ ScanWarning(WarningKind.TIMEOUT, "interrupted (KeyboardInterrupt)")
482
+ )
483
+ partial = partial or budget.timed_out
484
+
485
+ return ScanResult(
486
+ files=tuple(sorted(acc.files, key=lambda f: f.path)),
487
+ git=git_info,
488
+ mode=mode,
489
+ warnings=tuple(budget.warnings),
490
+ secret_count=acc.secret_count,
491
+ partial=partial,
492
+ truncated=budget.truncated,
493
+ elapsed_s=time.monotonic() - started,
494
+ hash_ms=acc.hash_ns // 1_000_000,
495
+ )