worktree-shield 0.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.
- worktree_shield/__init__.py +8 -0
- worktree_shield/__main__.py +6 -0
- worktree_shield/cli.py +377 -0
- worktree_shield/collectors/__init__.py +1 -0
- worktree_shield/collectors/base.py +63 -0
- worktree_shield/collectors/compose.py +315 -0
- worktree_shield/collectors/envfiles.py +130 -0
- worktree_shield/collectors/layout.py +106 -0
- worktree_shield/collectors/node.py +87 -0
- worktree_shield/collectors/procfile.py +134 -0
- worktree_shield/collectors/python.py +128 -0
- worktree_shield/collectors/runtime.py +167 -0
- worktree_shield/collectors/static.py +25 -0
- worktree_shield/collectors/vite.py +133 -0
- worktree_shield/collisions.py +115 -0
- worktree_shield/config.py +188 -0
- worktree_shield/exit_codes.py +22 -0
- worktree_shield/files.py +70 -0
- worktree_shield/git/__init__.py +1 -0
- worktree_shield/git/discovery.py +85 -0
- worktree_shield/git/porcelain.py +48 -0
- worktree_shield/models.py +356 -0
- worktree_shield/names.py +83 -0
- worktree_shield/orchestrator.py +121 -0
- worktree_shield/overlap.py +175 -0
- worktree_shield/parsers/__init__.py +1 -0
- worktree_shield/parsers/commands.py +158 -0
- worktree_shield/parsers/compose.py +148 -0
- worktree_shield/parsers/dburl.py +156 -0
- worktree_shield/parsers/dotenv.py +91 -0
- worktree_shield/parsers/interpolate.py +142 -0
- worktree_shield/parsers/jsonc.py +48 -0
- worktree_shield/parsers/jstext.py +142 -0
- worktree_shield/plan.py +175 -0
- worktree_shield/privacy.py +237 -0
- worktree_shield/reports/__init__.py +1 -0
- worktree_shield/reports/findings.py +176 -0
- worktree_shield/reports/json_report.py +61 -0
- worktree_shield/reports/markdown.py +103 -0
- worktree_shield/reports/sarif.py +116 -0
- worktree_shield/reports/terminal.py +327 -0
- worktree_shield/rules/__init__.py +1 -0
- worktree_shield/rules/catalog.py +28 -0
- worktree_shield/rules/common.py +61 -0
- worktree_shield/rules/context.py +201 -0
- worktree_shield/rules/engine.py +60 -0
- worktree_shield/rules/explain.py +27 -0
- worktree_shield/rules/rules.md +112 -0
- worktree_shield/rules/runtime.py +164 -0
- worktree_shield/rules/shared.py +216 -0
- worktree_shield/runner.py +273 -0
- worktree_shield-0.1.0.dist-info/METADATA +151 -0
- worktree_shield-0.1.0.dist-info/RECORD +56 -0
- worktree_shield-0.1.0.dist-info/WHEEL +4 -0
- worktree_shield-0.1.0.dist-info/entry_points.txt +4 -0
- worktree_shield-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Docker Compose: project names, container names, published ports and mounts.
|
|
2
|
+
|
|
3
|
+
Files are chosen as compose-go chooses them: the first of :data:`COMPOSE_FILES` in a
|
|
4
|
+
directory, plus the first of :data:`OVERRIDE_FILES` next to it. They are loaded with
|
|
5
|
+
``yaml.SafeLoader`` (pure Python: deep nesting raises ``RecursionError`` instead of
|
|
6
|
+
overflowing the C stack), which builds only plain data. Nothing is passed to Docker.
|
|
7
|
+
|
|
8
|
+
The project name follows compose-go's precedence: ``COMPOSE_PROJECT_NAME`` (from
|
|
9
|
+
``.env``, when env files are read), then the last non-empty ``name:``, then the name of
|
|
10
|
+
the directory, normalised. It prefixes containers, networks and named volumes, so two
|
|
11
|
+
worktrees with the same project name share all of them; a ``name:`` in the file, or a
|
|
12
|
+
compose file in a subdirectory such as ``infra/``, gives every worktree the same one.
|
|
13
|
+
|
|
14
|
+
Overrides are merged the simple way: a service's ``container_name`` is the last one
|
|
15
|
+
set; ports and volumes of all files are listed together.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path, PurePosixPath
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from worktree_shield.collectors.base import (
|
|
28
|
+
Collected,
|
|
29
|
+
Scope,
|
|
30
|
+
is_absolute_path,
|
|
31
|
+
path_key,
|
|
32
|
+
port_key,
|
|
33
|
+
)
|
|
34
|
+
from worktree_shield.models import (
|
|
35
|
+
Policy,
|
|
36
|
+
ResourceClaim,
|
|
37
|
+
ResourceKind,
|
|
38
|
+
SkippedFile,
|
|
39
|
+
SourceRef,
|
|
40
|
+
UnreadableValue,
|
|
41
|
+
)
|
|
42
|
+
from worktree_shield.parsers.compose import parse_mount, published_ports
|
|
43
|
+
from worktree_shield.parsers.dotenv import parse_dotenv
|
|
44
|
+
from worktree_shield.parsers.interpolate import Interpolated, interpolate
|
|
45
|
+
|
|
46
|
+
COMPOSE_FILES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
|
|
47
|
+
OVERRIDE_FILES = (
|
|
48
|
+
"compose.override.yml",
|
|
49
|
+
"compose.override.yaml",
|
|
50
|
+
"docker-compose.override.yml",
|
|
51
|
+
"docker-compose.override.yaml",
|
|
52
|
+
)
|
|
53
|
+
_PROJECT_CHARS = re.compile(r"[a-z0-9_-]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_project_name(name: str) -> str:
|
|
57
|
+
"""compose-go's NormalizeProjectName: lower case, ``[a-z0-9_-]`` only, no leading
|
|
58
|
+
``_`` or ``-``."""
|
|
59
|
+
return "".join(_PROJECT_CHARS.findall(name.lower())).lstrip("_-")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class _Document:
|
|
64
|
+
relative: PurePosixPath
|
|
65
|
+
node: yaml.Node
|
|
66
|
+
data: Any
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class _Service:
|
|
71
|
+
container_name: tuple[_Document, Any] | None = None
|
|
72
|
+
ports: list[tuple[_Document, int, Any]] = field(default_factory=list)
|
|
73
|
+
volumes: list[tuple[_Document, int, Any]] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def collect_compose(scope: Scope, directory: PurePosixPath, policy: Policy, out: Collected) -> None:
|
|
77
|
+
"""The Compose resources of the project in ``directory``, if it has one."""
|
|
78
|
+
main = _first(scope, directory, COMPOSE_FILES)
|
|
79
|
+
if main is None:
|
|
80
|
+
return
|
|
81
|
+
documents = _load(scope, directory / main, out)
|
|
82
|
+
if documents is None:
|
|
83
|
+
return # unreadable (recorded as skipped): even the project name is unknown
|
|
84
|
+
override = _first(scope, directory, OVERRIDE_FILES)
|
|
85
|
+
if override is not None:
|
|
86
|
+
documents += _load(scope, directory / override, out) or []
|
|
87
|
+
variables = _env_variables(scope, directory, out) if policy.include_env_files else {}
|
|
88
|
+
project, source, detail = _project(scope, directory, main, documents, variables)
|
|
89
|
+
out.claims.append(
|
|
90
|
+
ResourceClaim(
|
|
91
|
+
ResourceKind.COMPOSE_PROJECT,
|
|
92
|
+
f"compose-project://{project}",
|
|
93
|
+
scope.worktree,
|
|
94
|
+
source,
|
|
95
|
+
detail,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
_Project(scope, project, variables, documents, out).collect()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _first(scope: Scope, directory: PurePosixPath, names: tuple[str, ...]) -> str | None:
|
|
102
|
+
folder = Path(scope.root, *directory.parts)
|
|
103
|
+
return next((name for name in names if (folder / name).is_file()), None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load(scope: Scope, relative: PurePosixPath, out: Collected) -> list[_Document] | None:
|
|
107
|
+
"""Every YAML document of a file, as nodes (for lines) and plain data; ``None``
|
|
108
|
+
when the file could not be read (recorded in ``out.skipped``)."""
|
|
109
|
+
text = scope.read(relative, out)
|
|
110
|
+
if text is None:
|
|
111
|
+
return None
|
|
112
|
+
documents: list[_Document] = []
|
|
113
|
+
loader = yaml.SafeLoader(text)
|
|
114
|
+
try:
|
|
115
|
+
while loader.check_node(): # also consumes the stream start get_node() expects
|
|
116
|
+
node = loader.get_node()
|
|
117
|
+
if node is None: # pragma: no cover - check_node() promised a document
|
|
118
|
+
break
|
|
119
|
+
documents.append(_Document(relative, node, loader.construct_document(node)))
|
|
120
|
+
except yaml.YAMLError:
|
|
121
|
+
out.skipped.append(SkippedFile(scope.source(relative), "is not valid YAML"))
|
|
122
|
+
return None
|
|
123
|
+
except RecursionError:
|
|
124
|
+
out.skipped.append(SkippedFile(scope.source(relative), "is nested too deeply"))
|
|
125
|
+
return None
|
|
126
|
+
finally:
|
|
127
|
+
loader.dispose()
|
|
128
|
+
return documents
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _env_variables(scope: Scope, directory: PurePosixPath, out: Collected) -> dict[str, str]:
|
|
132
|
+
"""The variables of ``.env`` next to the compose file, which Compose reads for
|
|
133
|
+
interpolation; references to earlier variables are resolved as Compose does."""
|
|
134
|
+
text = scope.read(directory / ".env", out)
|
|
135
|
+
variables: dict[str, str] = {}
|
|
136
|
+
for entry in parse_dotenv(text or ""):
|
|
137
|
+
if not entry.reference:
|
|
138
|
+
variables[entry.key] = entry.value
|
|
139
|
+
continue
|
|
140
|
+
resolved = interpolate(entry.value, variables)
|
|
141
|
+
if resolved.value is not None:
|
|
142
|
+
variables[entry.key] = resolved.value
|
|
143
|
+
return variables
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _project(
|
|
147
|
+
scope: Scope,
|
|
148
|
+
directory: PurePosixPath,
|
|
149
|
+
first_file: str,
|
|
150
|
+
documents: list[_Document],
|
|
151
|
+
variables: dict[str, str],
|
|
152
|
+
) -> tuple[str, SourceRef, str]:
|
|
153
|
+
"""The project name, where it comes from and how to describe it."""
|
|
154
|
+
from_env = variables.get("COMPOSE_PROJECT_NAME", "")
|
|
155
|
+
if from_env:
|
|
156
|
+
source = scope.source(directory / ".env")
|
|
157
|
+
return normalize_project_name(from_env), source, f"COMPOSE_PROJECT_NAME={from_env} (.env)"
|
|
158
|
+
named: tuple[str, _Document] | None = None
|
|
159
|
+
for document in documents:
|
|
160
|
+
if isinstance(document.data, dict) and isinstance(document.data.get("name"), str):
|
|
161
|
+
value = interpolate(document.data["name"], variables).value
|
|
162
|
+
if value:
|
|
163
|
+
named = (value, document)
|
|
164
|
+
if named is not None:
|
|
165
|
+
value, document = named
|
|
166
|
+
source = scope.source(document.relative, _line(document.node, "name"))
|
|
167
|
+
return normalize_project_name(value), source, f"name: {value}"
|
|
168
|
+
folder = Path(scope.root, *directory.parts).name
|
|
169
|
+
return (
|
|
170
|
+
normalize_project_name(folder),
|
|
171
|
+
scope.source(directory / first_file),
|
|
172
|
+
f"directory name {folder}",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _Project:
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
scope: Scope,
|
|
180
|
+
project: str,
|
|
181
|
+
variables: dict[str, str],
|
|
182
|
+
documents: list[_Document],
|
|
183
|
+
out: Collected,
|
|
184
|
+
) -> None:
|
|
185
|
+
self.scope, self.project, self.variables = scope, project, variables
|
|
186
|
+
self.documents, self.out = documents, out
|
|
187
|
+
|
|
188
|
+
def collect(self) -> None:
|
|
189
|
+
services: dict[str, _Service] = {}
|
|
190
|
+
volumes: dict[str, Any] = {}
|
|
191
|
+
for document in self.documents:
|
|
192
|
+
data = document.data if isinstance(document.data, dict) else {}
|
|
193
|
+
if isinstance(data.get("volumes"), dict):
|
|
194
|
+
volumes.update(data["volumes"])
|
|
195
|
+
entries = data.get("services")
|
|
196
|
+
if not isinstance(entries, dict):
|
|
197
|
+
continue
|
|
198
|
+
for name, service in entries.items():
|
|
199
|
+
if not isinstance(name, str) or not isinstance(service, dict):
|
|
200
|
+
continue
|
|
201
|
+
merged = services.setdefault(name, _Service())
|
|
202
|
+
if "container_name" in service:
|
|
203
|
+
merged.container_name = (document, service["container_name"])
|
|
204
|
+
for key, target in (("ports", merged.ports), ("volumes", merged.volumes)):
|
|
205
|
+
if isinstance(service.get(key), list):
|
|
206
|
+
target.extend((document, i, e) for i, e in enumerate(service[key]))
|
|
207
|
+
for name, service in services.items():
|
|
208
|
+
if service.container_name is not None:
|
|
209
|
+
self.container_name(name, *service.container_name)
|
|
210
|
+
for document, index, entry in service.ports:
|
|
211
|
+
self.port(name, document, index, entry)
|
|
212
|
+
for document, index, entry in service.volumes:
|
|
213
|
+
self.mount(name, document, index, entry, volumes)
|
|
214
|
+
|
|
215
|
+
def claim(self, kind: ResourceKind, key: str, source: SourceRef, detail: str) -> None:
|
|
216
|
+
self.out.claims.append(ResourceClaim(kind, key, self.scope.worktree, source, detail))
|
|
217
|
+
|
|
218
|
+
def unreadable(self, kind: ResourceKind, name: str, source: SourceRef, reason: str) -> None:
|
|
219
|
+
self.out.unreadable.append(UnreadableValue(kind, source, name, reason))
|
|
220
|
+
|
|
221
|
+
def resolve(self, value: Any) -> tuple[Any, str | None]:
|
|
222
|
+
"""The value with its variables filled in, or ``None`` and the reason why not.
|
|
223
|
+
Strings inside a long-syntax mapping are filled in too."""
|
|
224
|
+
if isinstance(value, str):
|
|
225
|
+
result = interpolate(value, self.variables)
|
|
226
|
+
return (result.value, None) if result.value is not None else (None, _why(result))
|
|
227
|
+
if isinstance(value, dict):
|
|
228
|
+
resolved: dict[Any, Any] = {}
|
|
229
|
+
for key, item in value.items():
|
|
230
|
+
item_value, reason = self.resolve(item)
|
|
231
|
+
if reason is not None:
|
|
232
|
+
return None, reason
|
|
233
|
+
resolved[key] = item_value
|
|
234
|
+
return resolved, None
|
|
235
|
+
return value, None
|
|
236
|
+
|
|
237
|
+
def source(self, document: _Document, *path: str | int) -> SourceRef:
|
|
238
|
+
return self.scope.source(document.relative, _line(document.node, "services", *path))
|
|
239
|
+
|
|
240
|
+
def container_name(self, service: str, document: _Document, value: Any) -> None:
|
|
241
|
+
source = self.source(document, service, "container_name")
|
|
242
|
+
label = f"services.{service}.container_name"
|
|
243
|
+
name, reason = self.resolve(value)
|
|
244
|
+
if reason is not None:
|
|
245
|
+
self.unreadable(ResourceKind.CONTAINER_NAME, label, source, reason)
|
|
246
|
+
elif isinstance(name, str) and name:
|
|
247
|
+
key = f"container://{name}"
|
|
248
|
+
self.claim(ResourceKind.CONTAINER_NAME, key, source, f"{label}: {name}")
|
|
249
|
+
|
|
250
|
+
def port(self, service: str, document: _Document, index: int, entry: Any) -> None:
|
|
251
|
+
source = self.source(document, service, "ports", index)
|
|
252
|
+
label = f"services.{service}.ports"
|
|
253
|
+
value, reason = self.resolve(entry)
|
|
254
|
+
ports = published_ports(value) if reason is None else None
|
|
255
|
+
if ports is None:
|
|
256
|
+
why = reason or "is not a valid port mapping"
|
|
257
|
+
self.unreadable(ResourceKind.LISTEN_PORT, f"{label}[{index}]", source, why)
|
|
258
|
+
return
|
|
259
|
+
shown = f"published {value.get('published')}" if isinstance(value, dict) else str(value)
|
|
260
|
+
for port in ports:
|
|
261
|
+
key = port_key(port.host_ip, port.port, protocol=port.protocol)
|
|
262
|
+
self.claim(ResourceKind.LISTEN_PORT, key, source, f"{label}: {shown}")
|
|
263
|
+
|
|
264
|
+
def mount(
|
|
265
|
+
self, service: str, document: _Document, index: int, entry: Any, volumes: dict[str, Any]
|
|
266
|
+
) -> None:
|
|
267
|
+
source = self.source(document, service, "volumes", index)
|
|
268
|
+
label = f"services.{service}.volumes"
|
|
269
|
+
value, reason = self.resolve(entry)
|
|
270
|
+
if reason is not None: # ${DATA_DIR}:/data: a host path nobody can tell
|
|
271
|
+
self.unreadable(ResourceKind.ABSOLUTE_PATH, f"{label}[{index}]", source, reason)
|
|
272
|
+
return
|
|
273
|
+
mount = parse_mount(value)
|
|
274
|
+
if mount is None or mount.read_only or mount.source is None:
|
|
275
|
+
return # read-only sharing is no collision; anonymous volumes are per container
|
|
276
|
+
if mount.kind == "bind":
|
|
277
|
+
if mount.source.startswith("~") or is_absolute_path(mount.source):
|
|
278
|
+
key = path_key(mount.source)
|
|
279
|
+
self.claim(ResourceKind.ABSOLUTE_PATH, key, source, f"{label}: {mount.source}")
|
|
280
|
+
return
|
|
281
|
+
if mount.kind != "volume":
|
|
282
|
+
return
|
|
283
|
+
settings = volumes.get(mount.source)
|
|
284
|
+
settings = settings if isinstance(settings, dict) else {}
|
|
285
|
+
fixed = settings.get("name")
|
|
286
|
+
external = settings.get("external") is True
|
|
287
|
+
if isinstance(fixed, str) and fixed:
|
|
288
|
+
name = interpolate(fixed, self.variables).value or fixed
|
|
289
|
+
elif external:
|
|
290
|
+
name = mount.source
|
|
291
|
+
else:
|
|
292
|
+
name = f"{self.project}_{mount.source}"
|
|
293
|
+
detail = f"{label}: {mount.source}" + (" (external)" if external else "")
|
|
294
|
+
self.claim(ResourceKind.VOLUME, f"volume://{name}", source, detail)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _why(result: Interpolated) -> str:
|
|
298
|
+
if result.missing:
|
|
299
|
+
return f"depends on {', '.join(result.missing)}, which no file that was read sets"
|
|
300
|
+
return "has an invalid ${...} reference"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _line(node: yaml.Node, *path: str | int) -> int | None:
|
|
304
|
+
"""The line of the value at ``path`` in a node tree, or ``None`` if not found."""
|
|
305
|
+
for step in path:
|
|
306
|
+
found: yaml.Node | None = None
|
|
307
|
+
if isinstance(node, yaml.MappingNode) and isinstance(step, str):
|
|
308
|
+
pairs = ((k, v) for k, v in node.value if isinstance(k, yaml.ScalarNode))
|
|
309
|
+
found = next((v for k, v in pairs if k.value == step), None)
|
|
310
|
+
elif isinstance(node, yaml.SequenceNode) and isinstance(step, int):
|
|
311
|
+
found = node.value[step] if step < len(node.value) else None
|
|
312
|
+
if found is None:
|
|
313
|
+
return None # reached through a merge key or an odd shape
|
|
314
|
+
node = found
|
|
315
|
+
return node.start_mark.line + 1
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Ports, databases and absolute paths declared in ``.env`` files.
|
|
2
|
+
|
|
3
|
+
Templates (``.env.example``, ``.env.sample``, ``.env.template``) are always read: they
|
|
4
|
+
are committed and meant to be shared. ``.env`` and ``.env.local`` hold real values,
|
|
5
|
+
often secrets, and are read only with ``include_env_files``; then a key they set
|
|
6
|
+
replaces the templates' value for it, as dotenv tools load ``.env.local`` over ``.env``
|
|
7
|
+
and never read a template. Either way a value leaves
|
|
8
|
+
this module only as a port number, a database without credentials or a path; the
|
|
9
|
+
values of every other variable are dropped.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
|
|
17
|
+
from worktree_shield.collectors.base import (
|
|
18
|
+
Collected,
|
|
19
|
+
Scope,
|
|
20
|
+
is_absolute_path,
|
|
21
|
+
path_key,
|
|
22
|
+
port_key,
|
|
23
|
+
)
|
|
24
|
+
from worktree_shield.models import (
|
|
25
|
+
Policy,
|
|
26
|
+
ResourceClaim,
|
|
27
|
+
ResourceKind,
|
|
28
|
+
SkippedFile,
|
|
29
|
+
SourceRef,
|
|
30
|
+
UnreadableValue,
|
|
31
|
+
)
|
|
32
|
+
from worktree_shield.names import (
|
|
33
|
+
host_variable,
|
|
34
|
+
is_mutable_path_variable,
|
|
35
|
+
literal_host,
|
|
36
|
+
port_number,
|
|
37
|
+
port_variable,
|
|
38
|
+
)
|
|
39
|
+
from worktree_shield.parsers.dburl import DatabaseURLError, parse_database_url
|
|
40
|
+
from worktree_shield.parsers.dotenv import EnvEntry, parse_dotenv
|
|
41
|
+
|
|
42
|
+
TEMPLATE_FILES = (".env.example", ".env.sample", ".env.template")
|
|
43
|
+
LOCAL_FILES = (".env", ".env.local")
|
|
44
|
+
NOT_READ = "was not read: .env files are read only with --include-env"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def collect_env_files(
|
|
48
|
+
scope: Scope, directory: PurePosixPath, policy: Policy, out: Collected
|
|
49
|
+
) -> None:
|
|
50
|
+
"""The claims of the ``.env`` files in one directory of a worktree."""
|
|
51
|
+
names = TEMPLATE_FILES + (LOCAL_FILES if policy.include_env_files else ())
|
|
52
|
+
if not policy.include_env_files:
|
|
53
|
+
folder = Path(scope.root, *directory.parts)
|
|
54
|
+
for name in LOCAL_FILES: # noted, not opened: what runs may differ from what was read
|
|
55
|
+
if os.path.lexists(folder / name):
|
|
56
|
+
out.skipped.append(SkippedFile(scope.source(directory / name), NOT_READ))
|
|
57
|
+
files: list[tuple[PurePosixPath, tuple[EnvEntry, ...]]] = []
|
|
58
|
+
for name in names:
|
|
59
|
+
relative = directory / name
|
|
60
|
+
text = scope.read(relative, out)
|
|
61
|
+
if text is not None:
|
|
62
|
+
files.append((relative, parse_dotenv(text)))
|
|
63
|
+
for index, (relative, entries) in enumerate(files):
|
|
64
|
+
later = files[index + 1 :] # in names order: templates, then .env, then .env.local
|
|
65
|
+
overridden = {
|
|
66
|
+
e.key.upper() for path, found in later if path.name in LOCAL_FILES for e in found
|
|
67
|
+
}
|
|
68
|
+
_collect(scope, relative, entries, out, skip=overridden)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _collect(
|
|
72
|
+
scope: Scope,
|
|
73
|
+
relative: PurePosixPath,
|
|
74
|
+
entries: tuple[EnvEntry, ...],
|
|
75
|
+
out: Collected,
|
|
76
|
+
*,
|
|
77
|
+
skip: set[str],
|
|
78
|
+
) -> None:
|
|
79
|
+
"""The claims of one file, except for keys a higher-priority local file sets."""
|
|
80
|
+
by_name = {entry.key.upper(): entry for entry in entries}
|
|
81
|
+
for entry in entries:
|
|
82
|
+
if entry.reference or entry.key.upper() in skip:
|
|
83
|
+
continue
|
|
84
|
+
value = entry.value.strip()
|
|
85
|
+
source = scope.source(relative, entry.line)
|
|
86
|
+
try:
|
|
87
|
+
target = parse_database_url(value)
|
|
88
|
+
except DatabaseURLError as error:
|
|
89
|
+
out.unreadable.append(
|
|
90
|
+
UnreadableValue(ResourceKind.DATABASE, source, entry.key, str(error))
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
if target is not None:
|
|
94
|
+
if target.engine == "sqlite" and not is_absolute_path(target.database or ""):
|
|
95
|
+
continue # relative to each worktree, so never shared
|
|
96
|
+
detail = entry.key # the key already names the database
|
|
97
|
+
out.claims.append(
|
|
98
|
+
ResourceClaim(ResourceKind.DATABASE, target.key, scope.worktree, source, detail)
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
port = port_number(value)
|
|
102
|
+
kind = port_variable(entry.key)
|
|
103
|
+
if port is not None and kind is not None:
|
|
104
|
+
out.claims.append(_port_claim(scope, source, entry, kind, port, by_name))
|
|
105
|
+
elif is_mutable_path_variable(entry.key) and is_absolute_path(value):
|
|
106
|
+
detail = f"{entry.key}={value}"
|
|
107
|
+
out.claims.append(
|
|
108
|
+
ResourceClaim(
|
|
109
|
+
ResourceKind.ABSOLUTE_PATH, path_key(value), scope.worktree, source, detail
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _port_claim(
|
|
115
|
+
scope: Scope,
|
|
116
|
+
source: SourceRef,
|
|
117
|
+
entry: EnvEntry,
|
|
118
|
+
kind: ResourceKind,
|
|
119
|
+
port: int,
|
|
120
|
+
by_name: dict[str, EnvEntry],
|
|
121
|
+
) -> ResourceClaim:
|
|
122
|
+
host_entry = by_name.get(host_variable(entry.key))
|
|
123
|
+
host = None
|
|
124
|
+
if host_entry is not None and not host_entry.reference:
|
|
125
|
+
host = literal_host(host_entry.value)
|
|
126
|
+
default = "localhost" if kind is ResourceKind.CLIENT_ENDPOINT else "*"
|
|
127
|
+
detail = f"{entry.key}={port}"
|
|
128
|
+
if host_entry is not None and host is not None:
|
|
129
|
+
detail += f" ({host_entry.key}={host_entry.value.strip()})"
|
|
130
|
+
return ResourceClaim(kind, port_key(host, port, default), scope.worktree, source, detail)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Which directories of a worktree hold configuration worth reading.
|
|
2
|
+
|
|
3
|
+
Monorepos keep a ``package.json`` or ``.env.example`` per app (``apps/web``,
|
|
4
|
+
``services/api``), so the root alone is not enough, but the whole tree is too much:
|
|
5
|
+
the walk lists directories only (no file is opened here), goes at most
|
|
6
|
+
:data:`MAX_DEPTH` levels down, never follows a link to a directory, and skips
|
|
7
|
+
dependencies, build output, test fixtures and examples, which declare ports nobody runs
|
|
8
|
+
from this worktree.
|
|
9
|
+
|
|
10
|
+
A directory with a ``.git`` entry of its own is another worktree or a submodule and is
|
|
11
|
+
not entered: worktrees often live inside the main one (``repo/worktrees/feature``), and
|
|
12
|
+
their files must not be counted twice, once as the main worktree's.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from collections import deque
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
|
|
21
|
+
from worktree_shield.collectors.base import Collected, Scope
|
|
22
|
+
from worktree_shield.collectors.compose import COMPOSE_FILES, OVERRIDE_FILES
|
|
23
|
+
from worktree_shield.collectors.envfiles import LOCAL_FILES, TEMPLATE_FILES
|
|
24
|
+
from worktree_shield.collectors.vite import CONFIG_FILES as VITE_FILES
|
|
25
|
+
from worktree_shield.models import SkippedFile
|
|
26
|
+
|
|
27
|
+
MAX_DEPTH = 3
|
|
28
|
+
MAX_DIRECTORIES = 2000
|
|
29
|
+
CONFIG_NAMES = frozenset(
|
|
30
|
+
(
|
|
31
|
+
*TEMPLATE_FILES,
|
|
32
|
+
*LOCAL_FILES,
|
|
33
|
+
"package.json",
|
|
34
|
+
"pyproject.toml",
|
|
35
|
+
*VITE_FILES,
|
|
36
|
+
*COMPOSE_FILES,
|
|
37
|
+
*OVERRIDE_FILES,
|
|
38
|
+
"Procfile",
|
|
39
|
+
"Procfile.dev",
|
|
40
|
+
".devcontainer",
|
|
41
|
+
".devcontainer.json",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
SKIPPED_DIRECTORIES = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"node_modules",
|
|
47
|
+
"bower_components",
|
|
48
|
+
"vendor",
|
|
49
|
+
"third_party",
|
|
50
|
+
"site-packages",
|
|
51
|
+
"venv",
|
|
52
|
+
"dist",
|
|
53
|
+
"build",
|
|
54
|
+
"out",
|
|
55
|
+
"target",
|
|
56
|
+
"coverage",
|
|
57
|
+
"__pycache__",
|
|
58
|
+
"fixtures",
|
|
59
|
+
"__fixtures__",
|
|
60
|
+
"testdata",
|
|
61
|
+
"examples",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def config_directories(scope: Scope, out: Collected) -> list[PurePosixPath]:
|
|
67
|
+
"""Directories (relative to the worktree, root first) that contain at least one
|
|
68
|
+
known configuration file, breadth first. A directory that cannot be listed, or a
|
|
69
|
+
walk that reaches :data:`MAX_DIRECTORIES`, is recorded in ``out.skipped``."""
|
|
70
|
+
found: list[PurePosixPath] = []
|
|
71
|
+
queue: deque[PurePosixPath] = deque([PurePosixPath()])
|
|
72
|
+
listed = 0
|
|
73
|
+
while queue:
|
|
74
|
+
relative = queue.popleft()
|
|
75
|
+
listed += 1
|
|
76
|
+
if listed > MAX_DIRECTORIES:
|
|
77
|
+
reason = f"was not searched: the walk stopped after {MAX_DIRECTORIES} directories"
|
|
78
|
+
out.skipped.append(SkippedFile(scope.source(relative), reason))
|
|
79
|
+
break
|
|
80
|
+
try:
|
|
81
|
+
with os.scandir(scope.root.joinpath(*relative.parts)) as listing:
|
|
82
|
+
entries = sorted(listing, key=lambda entry: entry.name)
|
|
83
|
+
except OSError as error:
|
|
84
|
+
reason = f"could not be listed ({error.strerror or error})"
|
|
85
|
+
out.skipped.append(SkippedFile(scope.source(relative), reason))
|
|
86
|
+
continue
|
|
87
|
+
if any(entry.name in CONFIG_NAMES for entry in entries):
|
|
88
|
+
found.append(relative)
|
|
89
|
+
if len(relative.parts) >= MAX_DEPTH:
|
|
90
|
+
continue
|
|
91
|
+
for entry in entries:
|
|
92
|
+
if _worth_entering(entry):
|
|
93
|
+
queue.append(relative / entry.name)
|
|
94
|
+
return found
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _worth_entering(entry: os.DirEntry[str]) -> bool:
|
|
98
|
+
name = entry.name
|
|
99
|
+
if name.startswith(".") or name in SKIPPED_DIRECTORIES:
|
|
100
|
+
return False
|
|
101
|
+
try:
|
|
102
|
+
if not entry.is_dir(follow_symlinks=False):
|
|
103
|
+
return False
|
|
104
|
+
return not os.path.lexists(Path(entry.path, ".git")) # another worktree or submodule
|
|
105
|
+
except OSError:
|
|
106
|
+
return False
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Listen ports in ``package.json``: every script, and npm's ``config.port``.
|
|
2
|
+
|
|
3
|
+
The file is parsed with :mod:`json`; scripts are scanned as text by
|
|
4
|
+
:func:`~worktree_shield.parsers.commands.listen_ports` and never run. Only the fragment
|
|
5
|
+
that names a port is kept, so other values on the same command line go nowhere.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from pathlib import PurePosixPath
|
|
14
|
+
|
|
15
|
+
from worktree_shield.collectors.base import Collected, Scope, port_key
|
|
16
|
+
from worktree_shield.models import ResourceClaim, ResourceKind, SkippedFile
|
|
17
|
+
from worktree_shield.names import port_number
|
|
18
|
+
from worktree_shield.parsers.commands import listen_ports
|
|
19
|
+
|
|
20
|
+
_TOKEN = re.compile(r'"(?:[^"\\]|\\.)*"|[{}\[\]:]', re.S)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def collect_package_json(scope: Scope, directory: PurePosixPath, out: Collected) -> None:
|
|
24
|
+
"""The listen ports in ``directory/package.json``, if there is one."""
|
|
25
|
+
relative = directory / "package.json"
|
|
26
|
+
text = scope.read(relative, out)
|
|
27
|
+
if text is None:
|
|
28
|
+
return
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(text)
|
|
31
|
+
except json.JSONDecodeError as error:
|
|
32
|
+
out.skipped.append(SkippedFile(scope.source(relative, error.lineno), "is not valid JSON"))
|
|
33
|
+
return
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
return
|
|
36
|
+
lines = _key_lines(text)
|
|
37
|
+
|
|
38
|
+
def claim(port: int, host: str | None, key: tuple[str, str], detail: str) -> ResourceClaim:
|
|
39
|
+
source = scope.source(relative, lines.get(key))
|
|
40
|
+
resource = port_key(host, port)
|
|
41
|
+
return ResourceClaim(ResourceKind.LISTEN_PORT, resource, scope.worktree, source, detail)
|
|
42
|
+
|
|
43
|
+
scripts = data.get("scripts")
|
|
44
|
+
if isinstance(scripts, dict):
|
|
45
|
+
for name, command in scripts.items():
|
|
46
|
+
if isinstance(command, str):
|
|
47
|
+
for mention in listen_ports(command):
|
|
48
|
+
found = claim(
|
|
49
|
+
mention.port,
|
|
50
|
+
mention.host,
|
|
51
|
+
("scripts", name),
|
|
52
|
+
f"scripts.{name}: {mention.fragment}",
|
|
53
|
+
)
|
|
54
|
+
out.claims.append(replace(found, strict=mention.strict))
|
|
55
|
+
config = data.get("config")
|
|
56
|
+
if isinstance(config, dict):
|
|
57
|
+
value = config.get("port")
|
|
58
|
+
if isinstance(value, (str, int)) and not isinstance(value, bool):
|
|
59
|
+
port = port_number(str(value))
|
|
60
|
+
if port is not None:
|
|
61
|
+
out.claims.append(claim(port, None, ("config", "port"), f"config.port={port}"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _key_lines(text: str) -> dict[tuple[str, ...], int]:
|
|
65
|
+
"""The line of every object key, by its path (``("scripts", "dev")``); for a key
|
|
66
|
+
defined twice the later line, as :mod:`json` keeps the later value."""
|
|
67
|
+
lines: dict[tuple[str, ...], int] = {}
|
|
68
|
+
tokens = list(_TOKEN.finditer(text))
|
|
69
|
+
opened_by: list[str | None] = [] # the key whose value each open container is
|
|
70
|
+
last_key: str | None = None
|
|
71
|
+
line, position = 1, 0
|
|
72
|
+
for index, match in enumerate(tokens):
|
|
73
|
+
piece = match.group()
|
|
74
|
+
if piece.startswith('"'):
|
|
75
|
+
if index + 1 < len(tokens) and tokens[index + 1].group() == ":":
|
|
76
|
+
last_key = json.loads(piece)
|
|
77
|
+
line += text.count("\n", position, match.start())
|
|
78
|
+
position = match.start()
|
|
79
|
+
path = tuple(key for key in opened_by if key is not None)
|
|
80
|
+
lines[(*path, last_key)] = line
|
|
81
|
+
elif piece in "{[":
|
|
82
|
+
opened_by.append(last_key)
|
|
83
|
+
last_key = None
|
|
84
|
+
elif piece in "}]":
|
|
85
|
+
opened_by.pop() # balanced: json.loads() accepted the text
|
|
86
|
+
last_key = None
|
|
87
|
+
return lines
|