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,134 @@
1
+ """Listen ports in Procfiles and dev containers.
2
+
3
+ ``Procfile`` and ``Procfile.dev`` (foreman, honcho, Heroku, Rails ``bin/dev``) hold one
4
+ ``name: command`` per line; each command is scanned by
5
+ :func:`~worktree_shield.parsers.commands.listen_ports` and never run.
6
+
7
+ A dev container (``.devcontainer/devcontainer.json`` or ``.devcontainer.json``) can
8
+ publish ``appPort`` on the host, which fails when the port is taken, and forward
9
+ ``forwardPorts`` to this machine, which the editor moves to another local port when it
10
+ is taken: those claims are not strict.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from pathlib import PurePosixPath
18
+ from typing import Any
19
+
20
+ from worktree_shield.collectors.base import Collected, Scope, port_key
21
+ from worktree_shield.models import ResourceClaim, ResourceKind, SkippedFile
22
+ from worktree_shield.names import port_number
23
+ from worktree_shield.parsers.commands import listen_ports
24
+ from worktree_shield.parsers.jsonc import strip_jsonc
25
+
26
+ PROCFILES = ("Procfile", "Procfile.dev")
27
+ DEVCONTAINERS = (".devcontainer/devcontainer.json", ".devcontainer.json")
28
+ _PROCESS = re.compile(r"([A-Za-z0-9_-]+):\s*(.+)")
29
+
30
+
31
+ def collect_procfiles(scope: Scope, directory: PurePosixPath, out: Collected) -> None:
32
+ """The listen ports of every process in the Procfiles of ``directory``."""
33
+ for name in PROCFILES:
34
+ relative = directory / name
35
+ text = scope.read(relative, out)
36
+ if text is None:
37
+ continue
38
+ for number, line in enumerate(text.splitlines(), start=1):
39
+ match = _PROCESS.fullmatch(line.strip())
40
+ if match is None or line.lstrip().startswith("#"):
41
+ continue
42
+ process, command = match.groups()
43
+ for mention in listen_ports(command):
44
+ out.claims.append(
45
+ ResourceClaim(
46
+ ResourceKind.LISTEN_PORT,
47
+ port_key(mention.host, mention.port),
48
+ scope.worktree,
49
+ scope.source(relative, number),
50
+ f"{name} {process}: {mention.fragment}",
51
+ strict=mention.strict,
52
+ )
53
+ )
54
+
55
+
56
+ def collect_devcontainer(scope: Scope, directory: PurePosixPath, out: Collected) -> None:
57
+ """The ports a dev container in ``directory`` publishes or forwards."""
58
+ for name in DEVCONTAINERS:
59
+ relative = directory / name
60
+ text = scope.read(relative, out)
61
+ if text is None:
62
+ continue
63
+ plain = strip_jsonc(text)
64
+ try:
65
+ data = json.loads(plain)
66
+ except json.JSONDecodeError as error:
67
+ out.skipped.append(
68
+ SkippedFile(scope.source(relative, error.lineno), "is not valid JSON")
69
+ )
70
+ continue
71
+ if not isinstance(data, dict):
72
+ continue
73
+ finder = _Lines(plain)
74
+ for key, strict in (("forwardPorts", False), ("appPort", True)):
75
+ entries = _entries(data.get(key), single_allowed=key == "appPort")
76
+ start = finder.key(key)
77
+ for entry in entries:
78
+ port = _port(entry, published=strict)
79
+ if port is None:
80
+ continue
81
+ host = "localhost" if key == "forwardPorts" else None
82
+ out.claims.append(
83
+ ResourceClaim(
84
+ ResourceKind.LISTEN_PORT,
85
+ port_key(host, port),
86
+ scope.worktree,
87
+ scope.source(relative, finder.value(entry, start)),
88
+ f"{key}: {entry}",
89
+ strict=strict,
90
+ )
91
+ )
92
+
93
+
94
+ def _entries(value: Any, *, single_allowed: bool) -> list[Any]:
95
+ """The entries of a port list; appPort may also be a single port (the spec says so,
96
+ forwardPorts must be an array)."""
97
+ if isinstance(value, list):
98
+ return value
99
+ return [value] if single_allowed and value is not None else []
100
+
101
+
102
+ def _port(entry: Any, *, published: bool) -> int | None:
103
+ """The local port of a forwardPorts or appPort entry: ``3000``, ``"db:5432"``
104
+ (forwarded service port) or ``"8080:80"`` (published host:container)."""
105
+ if isinstance(entry, bool):
106
+ return None
107
+ if isinstance(entry, int):
108
+ return port_number(str(entry))
109
+ if isinstance(entry, str):
110
+ first, _, second = entry.partition(":")
111
+ if not second:
112
+ return port_number(first)
113
+ return port_number(first if published else second)
114
+ return None
115
+
116
+
117
+ class _Lines:
118
+ """Lines of keys and array values in already parsed JSON text, found in order."""
119
+
120
+ def __init__(self, text: str) -> None:
121
+ self.text = text
122
+ self.position = 0
123
+
124
+ def key(self, key: str) -> int:
125
+ match = re.search(re.escape(json.dumps(key)) + r"\s*:", self.text)
126
+ return match.end() if match else len(self.text)
127
+
128
+ def value(self, entry: Any, start: int) -> int | None:
129
+ literal = re.escape(json.dumps(entry)) if isinstance(entry, str) else rf"\b{entry}\b"
130
+ match = re.compile(literal).search(self.text, max(start, self.position))
131
+ if match is None:
132
+ return None
133
+ self.position = match.end()
134
+ return self.text.count("\n", 0, match.start()) + 1
@@ -0,0 +1,128 @@
1
+ """Listen ports in the task runners of ``pyproject.toml``: poe, taskipy, pdm, hatch, rye.
2
+
3
+ The file is parsed with :mod:`tomllib`; task commands are scanned as text by
4
+ :func:`~worktree_shield.parsers.commands.listen_ports` and never run. Script tasks that
5
+ call Python functions (``script = "api:main"``) are not commands and are skipped. A
6
+ task's ``env`` table counts like a ``PORT=…`` prefix.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shlex
13
+ import tomllib
14
+ from collections.abc import Iterator
15
+ from pathlib import PurePosixPath
16
+ from typing import Any
17
+
18
+ from worktree_shield.collectors.base import Collected, Scope, port_key
19
+ from worktree_shield.models import ResourceClaim, ResourceKind, SkippedFile
20
+ from worktree_shield.names import port_number, port_variable
21
+ from worktree_shield.parsers.commands import PortMention, listen_ports
22
+
23
+ _MAX_DEPTH = 8 # sequences of sequences; real projects use two levels at most
24
+ _COMMAND_KEYS = ("cmd", "shell")
25
+ _SEQUENCE_KEYS = ("sequence", "composite", "chain")
26
+
27
+
28
+ def collect_pyproject(scope: Scope, directory: PurePosixPath, out: Collected) -> None:
29
+ """The listen ports in the task runners of ``directory/pyproject.toml``."""
30
+ relative = directory / "pyproject.toml"
31
+ text = scope.read(relative, out)
32
+ if text is None:
33
+ return
34
+ try:
35
+ data = tomllib.loads(text)
36
+ except tomllib.TOMLDecodeError:
37
+ out.skipped.append(SkippedFile(scope.source(relative), "is not valid TOML"))
38
+ return
39
+ found: list[ResourceClaim] = []
40
+ for section, tasks in _task_tables(data):
41
+ for name, task in tasks.items():
42
+ line = _line(text, section, name)
43
+ label = ".".join((*section, name))
44
+ for mention in _mentions(task, 0):
45
+ found.append(
46
+ ResourceClaim(
47
+ ResourceKind.LISTEN_PORT,
48
+ port_key(mention.host, mention.port),
49
+ scope.worktree,
50
+ scope.source(relative, line),
51
+ f"{label}: {mention.fragment}",
52
+ strict=mention.strict,
53
+ )
54
+ )
55
+ # In file order, whichever runner comes first; tasks without a known line last.
56
+ out.claims += sorted(found, key=lambda claim: claim.source.line or len(text) + 1)
57
+
58
+
59
+ def _task_tables(data: dict[str, Any]) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]:
60
+ tool = data.get("tool")
61
+ if not isinstance(tool, dict):
62
+ return
63
+ for runner, key in (
64
+ ("poe", "tasks"),
65
+ ("taskipy", "tasks"),
66
+ ("pdm", "scripts"),
67
+ ("rye", "scripts"),
68
+ ):
69
+ table = tool.get(runner)
70
+ if isinstance(table, dict) and isinstance(table.get(key), dict):
71
+ yield ("tool", runner, key), table[key]
72
+ hatch = tool.get("hatch")
73
+ envs = hatch.get("envs") if isinstance(hatch, dict) else None
74
+ if isinstance(envs, dict):
75
+ for env, settings in envs.items():
76
+ if isinstance(settings, dict) and isinstance(settings.get("scripts"), dict):
77
+ yield ("tool", "hatch", "envs", env, "scripts"), settings["scripts"]
78
+
79
+
80
+ def _mentions(task: Any, depth: int) -> list[PortMention]:
81
+ """The listen ports of one task in any of the documented shapes."""
82
+ if depth > _MAX_DEPTH:
83
+ return []
84
+ if isinstance(task, str):
85
+ return listen_ports(task)
86
+ if isinstance(task, list):
87
+ return [m for item in task for m in _mentions(item, depth + 1)]
88
+ if not isinstance(task, dict):
89
+ return []
90
+ found: list[PortMention] = []
91
+ for key in _COMMAND_KEYS:
92
+ command = task.get(key)
93
+ if isinstance(command, str):
94
+ found += listen_ports(command)
95
+ elif isinstance(command, list) and all(isinstance(arg, str) for arg in command):
96
+ found += listen_ports(shlex.join(command)) # an argument vector
97
+ for key in _SEQUENCE_KEYS:
98
+ if isinstance(task.get(key), list):
99
+ found += _mentions(task[key], depth + 1)
100
+ env = task.get("env")
101
+ if isinstance(env, dict):
102
+ for name, value in env.items():
103
+ port = port_number(value) if isinstance(value, str) else None
104
+ if port is not None and port_variable(name) is ResourceKind.LISTEN_PORT:
105
+ found.append(PortMention(port, None, f"env.{name}={port}"))
106
+ return found
107
+
108
+
109
+ def _line(text: str, section: tuple[str, ...], name: str) -> int | None:
110
+ """The line defining task ``name``: its own ``[section.name]`` table, or a
111
+ ``name = …`` line under ``[section]``. ``None`` when written some other way."""
112
+ dotted = r"\s*\.\s*".join(_key_pattern(part) for part in section)
113
+ own = re.compile(rf"^[ \t]*\[\s*{dotted}\s*\.\s*{_key_pattern(name)}\s*\]", re.M)
114
+ if match := own.search(text):
115
+ return text.count("\n", 0, match.start()) + 1
116
+ header = re.compile(rf"^[ \t]*\[\s*{dotted}\s*\]", re.M).search(text)
117
+ if header is None:
118
+ return None
119
+ following = re.compile(r"^[ \t]*\[", re.M).search(text, header.end())
120
+ end = following.start() if following else len(text)
121
+ key = re.compile(rf"^[ \t]*{_key_pattern(name)}\s*=", re.M).search(text, header.end(), end)
122
+ return text.count("\n", 0, key.start()) + 1 if key else None
123
+
124
+
125
+ def _key_pattern(key: str) -> str:
126
+ """A TOML key as written: bare, or in double or single quotes."""
127
+ escaped = re.escape(key)
128
+ return f"(?:{escaped}|\"{escaped}\"|'{escaped}')"
@@ -0,0 +1,167 @@
1
+ """Listening processes, tied to a worktree only by their working directory.
2
+
3
+ Read-only and without privileges: the listening sockets the system shows, and for each
4
+ owning process its name and working directory. Never its command line or environment,
5
+ which can hold secrets, and nothing is signalled. A process name like ``node`` says
6
+ nothing about the worktree, so it is never used to attribute one; a listener that
7
+ cannot be tied is kept with the reason, because it may still hold a port a worktree
8
+ needs.
9
+
10
+ The view is a snapshot: a server started a second later is not seen. On Linux the
11
+ sockets of other users' processes are listed without their owner; on macOS only the
12
+ current user's processes are visible.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass
20
+ from socket import SOCK_DGRAM, SOCK_STREAM
21
+ from typing import Any, Protocol
22
+
23
+ import psutil
24
+
25
+ from worktree_shield.collectors.base import port_key
26
+ from worktree_shield.models import Listener, Observation, ResourceClaim, ResourceKind, SourceRef
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Socket:
31
+ """A listening (TCP) or bound (UDP) local socket and the process owning it."""
32
+
33
+ protocol: str
34
+ address: str
35
+ port: int
36
+ pid: int | None
37
+
38
+
39
+ class ProcessGoneError(Exception):
40
+ """The process ended while it was looked at."""
41
+
42
+
43
+ class ProcessDeniedError(Exception):
44
+ """The process cannot be inspected without more privileges."""
45
+
46
+
47
+ class ProcessTable(Protocol):
48
+ def listening_sockets(self) -> tuple[list[Socket], str | None]:
49
+ """The sockets, and what limited the view (``None`` when nothing did)."""
50
+ ...
51
+
52
+ def process(self, pid: int) -> tuple[str, str | None]:
53
+ """Name and working directory (``None`` if unreadable) of a process."""
54
+ ...
55
+
56
+
57
+ def observe_runtime(
58
+ table: ProcessTable, worktrees: Sequence[str]
59
+ ) -> tuple[list[ResourceClaim], list[Listener], list[str]]:
60
+ """Runtime claims of the worktrees, the listeners no worktree owns, and the
61
+ limitations of the view."""
62
+ sockets, limitation = table.listening_sockets()
63
+ roots = sorted(((_canonical(w), w) for w in worktrees), key=lambda r: len(r[0]), reverse=True)
64
+ looked_up: dict[int, tuple[str | None, str | None, str | None]] = {}
65
+ claims: list[ResourceClaim] = []
66
+ unattributed: list[Listener] = []
67
+ for socket in sockets:
68
+ if socket.pid is None:
69
+ unattributed.append(_listener(socket, None, "owned by a process of another user"))
70
+ continue
71
+ if socket.pid not in looked_up:
72
+ looked_up[socket.pid] = _look_up(table, socket.pid, roots)
73
+ name, worktree, note = looked_up[socket.pid]
74
+ if worktree is None:
75
+ unattributed.append(_listener(socket, name, note or ""))
76
+ continue
77
+ key = port_key(socket.address, socket.port, protocol=socket.protocol)
78
+ source = SourceRef(worktree, f"pid {socket.pid}")
79
+ detail = f"{name} (pid {socket.pid}) is listening; it runs in this worktree"
80
+ claims.append(
81
+ ResourceClaim(
82
+ ResourceKind.LISTEN_PORT, key, worktree, source, detail, Observation.RUNTIME
83
+ )
84
+ )
85
+ return claims, unattributed, [limitation] if limitation else []
86
+
87
+
88
+ def _look_up(
89
+ table: ProcessTable, pid: int, roots: list[tuple[str, str]]
90
+ ) -> tuple[str | None, str | None, str | None]:
91
+ """(name, worktree, note): the worktree it runs in, or why there is none."""
92
+ try:
93
+ name, cwd = table.process(pid)
94
+ except ProcessGoneError:
95
+ return None, None, "the process ended while it was looked at"
96
+ except ProcessDeniedError:
97
+ return None, None, "the process cannot be inspected without more privileges"
98
+ if cwd is None:
99
+ return name, None, "its working directory cannot be read"
100
+ directory = _canonical(cwd)
101
+ for root, worktree in roots: # deepest first: nested worktrees
102
+ if directory == root or directory.startswith(root.rstrip(os.sep) + os.sep):
103
+ return name, worktree, None
104
+ return name, None, "it runs outside every worktree"
105
+
106
+
107
+ def _listener(socket: Socket, process: str | None, note: str) -> Listener:
108
+ return Listener(socket.protocol, socket.address, socket.port, socket.pid, process, note)
109
+
110
+
111
+ def _canonical(path: str) -> str:
112
+ return os.path.normcase(os.path.realpath(path))
113
+
114
+
115
+ class PsutilTable:
116
+ """The real process table, through psutil."""
117
+
118
+ def listening_sockets(self) -> tuple[list[Socket], str | None]:
119
+ found: list[Socket | None]
120
+ try:
121
+ found = [_socket(c, c.pid) for c in psutil.net_connections(kind="inet")]
122
+ limitation = None
123
+ except psutil.AccessDenied: # macOS: only the current user's processes
124
+ found = []
125
+ for process in psutil.process_iter():
126
+ try:
127
+ found += [_socket(c, process.pid) for c in process.net_connections(kind="inet")]
128
+ except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess):
129
+ continue
130
+ limitation = "only the processes of the current user are visible"
131
+ listening = [s for s in found if s is not None]
132
+ if limitation is None and any(s.pid is None for s in listening):
133
+ limitation = "the sockets of other users' processes are listed without their owner"
134
+ return listening, limitation
135
+
136
+ def process(self, pid: int) -> tuple[str, str | None]:
137
+ try:
138
+ process = psutil.Process(pid)
139
+ name = process.name()
140
+ try:
141
+ cwd: str | None = process.cwd()
142
+ except psutil.AccessDenied:
143
+ cwd = None
144
+ return name, cwd or None
145
+ except (psutil.NoSuchProcess, psutil.ZombieProcess):
146
+ raise ProcessGoneError from None
147
+ except psutil.AccessDenied:
148
+ raise ProcessDeniedError from None
149
+
150
+
151
+ def _socket(connection: Any, pid: int | None) -> Socket | None:
152
+ """A listening TCP or bound, unconnected UDP socket; ``None`` for anything else.
153
+ ``connection`` is a psutil ``sconn`` or ``pconn``; only the fields both have are used."""
154
+ if not connection.laddr:
155
+ return None
156
+ if connection.type == SOCK_STREAM:
157
+ if connection.status != psutil.CONN_LISTEN:
158
+ return None
159
+ protocol = "tcp"
160
+ elif connection.type == SOCK_DGRAM:
161
+ if connection.raddr:
162
+ return None
163
+ protocol = "udp"
164
+ else: # pragma: no cover - kind="inet" lists only TCP and UDP
165
+ return None
166
+ address, port = connection.laddr.ip, connection.laddr.port
167
+ return Socket(protocol, address.split("%", 1)[0], port, pid)
@@ -0,0 +1,25 @@
1
+ """All static collectors over one worktree: configuration files, read as data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from worktree_shield.collectors.base import Collected, Scope
6
+ from worktree_shield.collectors.compose import collect_compose
7
+ from worktree_shield.collectors.envfiles import collect_env_files
8
+ from worktree_shield.collectors.layout import config_directories
9
+ from worktree_shield.collectors.node import collect_package_json
10
+ from worktree_shield.collectors.procfile import collect_devcontainer, collect_procfiles
11
+ from worktree_shield.collectors.python import collect_pyproject
12
+ from worktree_shield.collectors.vite import collect_vite_config
13
+ from worktree_shield.models import Policy
14
+
15
+
16
+ def collect_worktree(scope: Scope, policy: Policy, out: Collected) -> None:
17
+ """Every claim in the configuration of one worktree, directory by directory."""
18
+ for directory in config_directories(scope, out):
19
+ collect_env_files(scope, directory, policy, out)
20
+ collect_package_json(scope, directory, out)
21
+ collect_vite_config(scope, directory, out)
22
+ collect_pyproject(scope, directory, out)
23
+ collect_compose(scope, directory, policy, out)
24
+ collect_procfiles(scope, directory, out)
25
+ collect_devcontainer(scope, directory, out)
@@ -0,0 +1,133 @@
1
+ """Ports and paths in ``vite.config.*``, matched as text; the config is never run.
2
+
3
+ Only literal settings count: ``server.port`` and ``preview.port`` (with ``host`` and
4
+ ``strictPort`` from the same block), ``cacheDir`` and ``build.outDir`` when absolute. A
5
+ port computed from the environment (``Number(process.env.PORT)``) is left to the
6
+ collector that reads the variable. Comments, strings and template literals are blanked
7
+ first, so a commented-out block or a string that looks like one is ignored.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import PurePosixPath
14
+
15
+ from worktree_shield.collectors.base import Collected, Scope, is_absolute_path, path_key, port_key
16
+ from worktree_shield.models import ResourceClaim, ResourceKind
17
+ from worktree_shield.names import literal_host, port_number
18
+ from worktree_shield.parsers.jstext import blank_code
19
+
20
+ CONFIG_FILES = tuple(f"vite.config.{ext}" for ext in ("js", "ts", "mjs", "mts", "cjs", "cts"))
21
+ _BLOCK = re.compile(r"\b(server|preview|build)\s*:\s*\{")
22
+ _SETTING = re.compile(r"\b(port|host|strictPort|outDir)\s*:\s*")
23
+ _CACHE_DIR = re.compile(r"\bcacheDir\s*:\s*")
24
+ _END = r"(?=\s*(?:,|\}|\n|$))" # the literal is the whole value
25
+ _NUMBER = re.compile(r"([0-9]+)" + _END)
26
+ _BOOLEAN = re.compile(r"(true|false)" + _END)
27
+
28
+ _Value = str | int | bool
29
+
30
+
31
+ def collect_vite_config(scope: Scope, directory: PurePosixPath, out: Collected) -> None:
32
+ """The claims of every ``vite.config.*`` in ``directory``."""
33
+ for name in CONFIG_FILES:
34
+ relative = directory / name
35
+ text = scope.read(relative, out)
36
+ if text is not None:
37
+ out.claims += _claims(scope, relative, text)
38
+
39
+
40
+ def _claims(scope: Scope, relative: PurePosixPath, text: str) -> list[ResourceClaim]:
41
+ code = blank_code(text)
42
+ ports: list[ResourceClaim] = []
43
+ paths: list[tuple[int, ResourceClaim]] = []
44
+
45
+ def line(position: int) -> int:
46
+ return text.count("\n", 0, position) + 1
47
+
48
+ def path_claim(position: int, label: str, value: _Value | None) -> None:
49
+ if isinstance(value, str) and is_absolute_path(value):
50
+ source = scope.source(relative, line(position))
51
+ claim = ResourceClaim(
52
+ ResourceKind.ABSOLUTE_PATH,
53
+ path_key(value),
54
+ scope.worktree,
55
+ source,
56
+ f"{label}: {value}",
57
+ )
58
+ paths.append((position, claim))
59
+
60
+ for block in _BLOCK.finditer(code):
61
+ settings = _settings(code, text, block.end() - 1)
62
+ kind = block.group(1)
63
+ if kind == "build":
64
+ if "outDir" in settings:
65
+ value, position = settings["outDir"]
66
+ path_claim(position, "build.outDir", value)
67
+ continue
68
+ if "port" not in settings:
69
+ continue
70
+ value, position = settings["port"]
71
+ port = (
72
+ port_number(str(value))
73
+ if isinstance(value, int) and not isinstance(value, bool)
74
+ else None
75
+ )
76
+ if port is None:
77
+ continue
78
+ host_value = settings.get("host", (None, 0))[0]
79
+ host = literal_host(host_value) if isinstance(host_value, str) else None
80
+ strict = settings.get("strictPort", (False, 0))[0] is True
81
+ source = scope.source(relative, line(position))
82
+ ports.append(
83
+ ResourceClaim(
84
+ ResourceKind.LISTEN_PORT,
85
+ port_key(host, port),
86
+ scope.worktree,
87
+ source,
88
+ f"{kind}.port: {port}",
89
+ strict=strict,
90
+ )
91
+ )
92
+ for match in _CACHE_DIR.finditer(code):
93
+ path_claim(match.start(), "cacheDir", _literal(code, text, match.end()))
94
+ return ports + [claim for _, claim in sorted(paths, key=lambda item: item[0])]
95
+
96
+
97
+ def _settings(code: str, text: str, opening: int) -> dict[str, tuple[_Value | None, int]]:
98
+ """The literal settings directly inside the object that opens at ``opening``
99
+ (nested objects such as ``hmr: { port }`` are skipped); empty if it never closes."""
100
+ depth = 0
101
+ found: dict[str, tuple[_Value | None, int]] = {}
102
+ index = opening + 1
103
+ while index < len(code):
104
+ char = code[index]
105
+ if char in "{[(":
106
+ depth += 1
107
+ elif char in "}])":
108
+ if depth == 0:
109
+ return found
110
+ depth -= 1
111
+ elif depth == 0 and (match := _SETTING.match(code, index)) and _starts_word(code, index):
112
+ found[match.group(1)] = (_literal(code, text, match.end()), index)
113
+ index = match.end()
114
+ continue
115
+ index += 1
116
+ return {}
117
+
118
+
119
+ def _starts_word(code: str, index: int) -> bool:
120
+ return index == 0 or not (code[index - 1].isalnum() or code[index - 1] in "_$")
121
+
122
+
123
+ def _literal(code: str, text: str, position: int) -> _Value | None:
124
+ """A number, boolean or string literal that is the whole value at ``position``."""
125
+ if number := _NUMBER.match(code, position):
126
+ return int(number.group(1))
127
+ if boolean := _BOOLEAN.match(code, position):
128
+ return boolean.group(1) == "true"
129
+ if position < len(code) and code[position] in "'\"":
130
+ closing = code.find(code[position], position + 1)
131
+ if closing > 0 and re.match(_END, code[closing + 1 :]):
132
+ return text[position + 1 : closing]
133
+ return None