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,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
|
+
)
|