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.
Files changed (56) hide show
  1. worktree_shield/__init__.py +8 -0
  2. worktree_shield/__main__.py +6 -0
  3. worktree_shield/cli.py +377 -0
  4. worktree_shield/collectors/__init__.py +1 -0
  5. worktree_shield/collectors/base.py +63 -0
  6. worktree_shield/collectors/compose.py +315 -0
  7. worktree_shield/collectors/envfiles.py +130 -0
  8. worktree_shield/collectors/layout.py +106 -0
  9. worktree_shield/collectors/node.py +87 -0
  10. worktree_shield/collectors/procfile.py +134 -0
  11. worktree_shield/collectors/python.py +128 -0
  12. worktree_shield/collectors/runtime.py +167 -0
  13. worktree_shield/collectors/static.py +25 -0
  14. worktree_shield/collectors/vite.py +133 -0
  15. worktree_shield/collisions.py +115 -0
  16. worktree_shield/config.py +188 -0
  17. worktree_shield/exit_codes.py +22 -0
  18. worktree_shield/files.py +70 -0
  19. worktree_shield/git/__init__.py +1 -0
  20. worktree_shield/git/discovery.py +85 -0
  21. worktree_shield/git/porcelain.py +48 -0
  22. worktree_shield/models.py +356 -0
  23. worktree_shield/names.py +83 -0
  24. worktree_shield/orchestrator.py +121 -0
  25. worktree_shield/overlap.py +175 -0
  26. worktree_shield/parsers/__init__.py +1 -0
  27. worktree_shield/parsers/commands.py +158 -0
  28. worktree_shield/parsers/compose.py +148 -0
  29. worktree_shield/parsers/dburl.py +156 -0
  30. worktree_shield/parsers/dotenv.py +91 -0
  31. worktree_shield/parsers/interpolate.py +142 -0
  32. worktree_shield/parsers/jsonc.py +48 -0
  33. worktree_shield/parsers/jstext.py +142 -0
  34. worktree_shield/plan.py +175 -0
  35. worktree_shield/privacy.py +237 -0
  36. worktree_shield/reports/__init__.py +1 -0
  37. worktree_shield/reports/findings.py +176 -0
  38. worktree_shield/reports/json_report.py +61 -0
  39. worktree_shield/reports/markdown.py +103 -0
  40. worktree_shield/reports/sarif.py +116 -0
  41. worktree_shield/reports/terminal.py +327 -0
  42. worktree_shield/rules/__init__.py +1 -0
  43. worktree_shield/rules/catalog.py +28 -0
  44. worktree_shield/rules/common.py +61 -0
  45. worktree_shield/rules/context.py +201 -0
  46. worktree_shield/rules/engine.py +60 -0
  47. worktree_shield/rules/explain.py +27 -0
  48. worktree_shield/rules/rules.md +112 -0
  49. worktree_shield/rules/runtime.py +164 -0
  50. worktree_shield/rules/shared.py +216 -0
  51. worktree_shield/runner.py +273 -0
  52. worktree_shield-0.1.0.dist-info/METADATA +151 -0
  53. worktree_shield-0.1.0.dist-info/RECORD +56 -0
  54. worktree_shield-0.1.0.dist-info/WHEEL +4 -0
  55. worktree_shield-0.1.0.dist-info/entry_points.txt +4 -0
  56. 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