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,91 @@
|
|
|
1
|
+
"""``.env`` files read as data, in the line format the common loaders agree on.
|
|
2
|
+
|
|
3
|
+
python-dotenv, Node's dotenv (and so Vite) and Docker Compose all accept ``KEY=value``,
|
|
4
|
+
an ``export`` prefix, spaces around ``=``, ``#`` comments, single, double and backtick
|
|
5
|
+
quotes, and quoted values over several lines. Other lines are ignored, as they are.
|
|
6
|
+
|
|
7
|
+
Nothing is expanded or run: ``${VAR}`` stays in the value and the entry is marked as a
|
|
8
|
+
*reference*, so a collector never treats ``${DATA_ROOT}/uploads`` as a literal path. An
|
|
9
|
+
unquoted ``#`` starts a comment only after a space or tab, as in python-dotenv and
|
|
10
|
+
Compose; Node's dotenv also cuts ``https://x/#a`` there, so such values are rare in
|
|
11
|
+
practice.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
_LINE = re.compile(r"[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_.-]*)[ \t]*=[ \t]*(.*)")
|
|
20
|
+
_REFERENCE = re.compile(r"\$(?:\{[A-Za-z_]|[A-Za-z_])")
|
|
21
|
+
_COMMENT = re.compile(r"[ \t]+#")
|
|
22
|
+
_ESCAPES = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
|
|
23
|
+
_QUOTES = "'\"`"
|
|
24
|
+
MAX_VALUE_LINES = 200 # a quoted value longer than this is treated as never closed
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class EnvEntry:
|
|
29
|
+
"""One variable; ``line`` is where it starts. The value never appears in repr()."""
|
|
30
|
+
|
|
31
|
+
key: str
|
|
32
|
+
value: str = field(repr=False)
|
|
33
|
+
line: int
|
|
34
|
+
reference: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_dotenv(text: str) -> tuple[EnvEntry, ...]:
|
|
38
|
+
"""The variables of a ``.env`` file; a later definition replaces an earlier one."""
|
|
39
|
+
lines = text.replace("\r\n", "\n").split("\n")
|
|
40
|
+
entries: dict[str, EnvEntry] = {}
|
|
41
|
+
index = 0
|
|
42
|
+
while index < len(lines):
|
|
43
|
+
number = index + 1
|
|
44
|
+
match = _LINE.fullmatch(lines[index])
|
|
45
|
+
index += 1
|
|
46
|
+
if match is None:
|
|
47
|
+
continue
|
|
48
|
+
key, rest = match.groups()
|
|
49
|
+
if rest[:1] and rest[0] in _QUOTES:
|
|
50
|
+
quoted = _quoted(rest[0], rest[1:], lines, index)
|
|
51
|
+
if quoted is None: # never closed: skip this line only
|
|
52
|
+
continue
|
|
53
|
+
value, index = quoted
|
|
54
|
+
reference = rest[0] != "'" and _REFERENCE.search(value) is not None
|
|
55
|
+
else:
|
|
56
|
+
comment = _COMMENT.search(rest)
|
|
57
|
+
value = (rest[: comment.start()] if comment else rest).rstrip(" \t")
|
|
58
|
+
reference = _REFERENCE.search(value) is not None
|
|
59
|
+
entries[key] = EnvEntry(key, value, number, reference)
|
|
60
|
+
return tuple(entries.values())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _quoted(quote: str, first: str, lines: list[str], index: int) -> tuple[str, int] | None:
|
|
64
|
+
"""The value of a quoted assignment and the index of the line after it."""
|
|
65
|
+
text = first
|
|
66
|
+
for extra in range(MAX_VALUE_LINES):
|
|
67
|
+
end = _closing(text, quote)
|
|
68
|
+
if end is not None:
|
|
69
|
+
body = text[:end]
|
|
70
|
+
return (_unescape(body) if quote == '"' else body), index + extra
|
|
71
|
+
if index + extra >= len(lines):
|
|
72
|
+
return None
|
|
73
|
+
text += "\n" + lines[index + extra]
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _closing(text: str, quote: str) -> int | None:
|
|
78
|
+
position = 0
|
|
79
|
+
while position < len(text):
|
|
80
|
+
char = text[position]
|
|
81
|
+
if char == "\\" and quote == '"':
|
|
82
|
+
position += 2
|
|
83
|
+
continue
|
|
84
|
+
if char == quote:
|
|
85
|
+
return position
|
|
86
|
+
position += 1
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _unescape(body: str) -> str:
|
|
91
|
+
return re.sub(r"\\(.)", lambda m: _ESCAPES.get(m.group(1), m.group(0)), body, flags=re.S)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Compose variable interpolation, resolved from the variables of the files read.
|
|
2
|
+
|
|
3
|
+
Implements the operators of the Compose specification: ``$VAR``, ``${VAR}``,
|
|
4
|
+
``${VAR:-default}`` / ``${VAR-default}``, ``${VAR:?error}`` / ``${VAR?error}``,
|
|
5
|
+
``${VAR:+replacement}`` / ``${VAR+replacement}``, nested defaults and ``$$`` for a
|
|
6
|
+
literal ``$``. Nothing is read from the environment of worktree-shield itself: the
|
|
7
|
+
shell that will run ``docker compose`` is not this one.
|
|
8
|
+
|
|
9
|
+
A variable that no file set is *assumed unset*: its default is used and the result
|
|
10
|
+
records the assumption, so a report can say "unless WEB_PORT is set in your shell". A
|
|
11
|
+
value that needs such a variable and has no default has no value (``None``) and the
|
|
12
|
+
variable is listed as *missing*; guessing an empty string, as Compose would, could
|
|
13
|
+
invent a collision.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
23
|
+
_OPERATOR = re.compile(r":?[-?+]")
|
|
24
|
+
MAX_NESTING = 16 # ${A:-${B:-…}} levels; more is not a real configuration
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Interpolated:
|
|
29
|
+
"""The resolved text, or ``None`` when it depends on a missing variable or the
|
|
30
|
+
syntax is invalid; ``missing`` and ``assumed_unset`` name variables in order."""
|
|
31
|
+
|
|
32
|
+
value: str | None
|
|
33
|
+
missing: tuple[str, ...] = ()
|
|
34
|
+
assumed_unset: tuple[str, ...] = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Invalid(Exception):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Missing(Exception):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def interpolate(text: str, variables: Mapping[str, str]) -> Interpolated:
|
|
46
|
+
"""``text`` with every variable reference resolved from ``variables``."""
|
|
47
|
+
resolver = _Resolver(variables, 0)
|
|
48
|
+
try:
|
|
49
|
+
value, _ = resolver.text(text, 0, closing=False)
|
|
50
|
+
except _Invalid:
|
|
51
|
+
return Interpolated(None)
|
|
52
|
+
value_or_none = None if resolver.missing else value
|
|
53
|
+
return Interpolated(value_or_none, tuple(resolver.missing), tuple(resolver.assumed))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Resolver:
|
|
57
|
+
def __init__(self, variables: Mapping[str, str], depth: int) -> None:
|
|
58
|
+
if depth > MAX_NESTING:
|
|
59
|
+
raise _Invalid
|
|
60
|
+
self.variables = variables
|
|
61
|
+
self.depth = depth
|
|
62
|
+
self.missing: list[str] = []
|
|
63
|
+
self.assumed: list[str] = []
|
|
64
|
+
|
|
65
|
+
def _note(self, names: list[str], name: str) -> None:
|
|
66
|
+
if name not in names:
|
|
67
|
+
names.append(name)
|
|
68
|
+
|
|
69
|
+
def _adopt(self, child: _Resolver) -> None:
|
|
70
|
+
"""Take over what a default needed, once the default is actually used."""
|
|
71
|
+
for name in child.assumed:
|
|
72
|
+
self._note(self.assumed, name)
|
|
73
|
+
for name in child.missing:
|
|
74
|
+
self._note(self.missing, name)
|
|
75
|
+
|
|
76
|
+
def text(self, text: str, index: int, *, closing: bool) -> tuple[str, int]:
|
|
77
|
+
"""Resolve from ``index`` to the end, or to the ``}`` closing a default when
|
|
78
|
+
``closing`` is set; returns the text and the index after it."""
|
|
79
|
+
parts: list[str] = []
|
|
80
|
+
while index < len(text):
|
|
81
|
+
char = text[index]
|
|
82
|
+
if closing and char == "}":
|
|
83
|
+
return "".join(parts), index
|
|
84
|
+
if char != "$":
|
|
85
|
+
parts.append(char)
|
|
86
|
+
index += 1
|
|
87
|
+
continue
|
|
88
|
+
following = text[index + 1 : index + 2]
|
|
89
|
+
if following == "$":
|
|
90
|
+
parts.append("$")
|
|
91
|
+
index += 2
|
|
92
|
+
elif following == "{":
|
|
93
|
+
value, index = self.braced(text, index + 2)
|
|
94
|
+
parts.append(value)
|
|
95
|
+
elif name := _NAME.match(text, index + 1):
|
|
96
|
+
parts.append(self.lookup(name.group(0)))
|
|
97
|
+
index = name.end()
|
|
98
|
+
else: # a lone "$" is kept, as Compose keeps it
|
|
99
|
+
parts.append("$")
|
|
100
|
+
index += 1
|
|
101
|
+
if closing:
|
|
102
|
+
raise _Invalid
|
|
103
|
+
return "".join(parts), index
|
|
104
|
+
|
|
105
|
+
def lookup(self, name: str) -> str:
|
|
106
|
+
if name in self.variables:
|
|
107
|
+
return self.variables[name]
|
|
108
|
+
self._note(self.missing, name)
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
def braced(self, text: str, index: int) -> tuple[str, int]:
|
|
112
|
+
"""``${…}`` starting after ``${``; returns its value and the index after ``}``."""
|
|
113
|
+
name_match = _NAME.match(text, index)
|
|
114
|
+
if name_match is None:
|
|
115
|
+
raise _Invalid
|
|
116
|
+
name, index = name_match.group(0), name_match.end()
|
|
117
|
+
if text[index : index + 1] == "}":
|
|
118
|
+
return self.lookup(name), index + 1
|
|
119
|
+
operator_match = _OPERATOR.match(text, index)
|
|
120
|
+
if operator_match is None:
|
|
121
|
+
raise _Invalid
|
|
122
|
+
operator = operator_match.group(0)
|
|
123
|
+
default = _Resolver(self.variables, self.depth + 1)
|
|
124
|
+
argument, end = default.text(text, operator_match.end(), closing=True)
|
|
125
|
+
known = name in self.variables
|
|
126
|
+
value = self.variables.get(name, "")
|
|
127
|
+
is_set = known and (value != "" or not operator.startswith(":"))
|
|
128
|
+
if operator.endswith("?"):
|
|
129
|
+
if not is_set:
|
|
130
|
+
self._note(self.missing, name) # Compose would stop with the error
|
|
131
|
+
return value, end + 1
|
|
132
|
+
if not known:
|
|
133
|
+
self._note(self.assumed, name)
|
|
134
|
+
if operator.endswith("-"):
|
|
135
|
+
if is_set:
|
|
136
|
+
return value, end + 1
|
|
137
|
+
self._adopt(default)
|
|
138
|
+
return argument, end + 1
|
|
139
|
+
if is_set: # "+": the replacement only when the variable is set
|
|
140
|
+
self._adopt(default)
|
|
141
|
+
return argument, end + 1
|
|
142
|
+
return "", end + 1
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""JSON with comments and trailing commas (as VS Code and dev containers allow) made
|
|
2
|
+
plain JSON, without moving any line.
|
|
3
|
+
|
|
4
|
+
Comments become spaces (their newlines are kept), and a comma that is followed only by
|
|
5
|
+
whitespace and a closing bracket is dropped. Strings are left exactly as they are, so
|
|
6
|
+
``"a // b"`` stays a string.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_TRAILING_COMMA = re.compile(r",(\s*[}\]])")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def strip_jsonc(text: str) -> str:
|
|
17
|
+
out: list[str] = []
|
|
18
|
+
index, length = 0, len(text)
|
|
19
|
+
while index < length:
|
|
20
|
+
char = text[index]
|
|
21
|
+
if char == '"':
|
|
22
|
+
end = index + 1
|
|
23
|
+
while end < length and text[end] != '"':
|
|
24
|
+
end += 2 if text[end] == "\\" else 1
|
|
25
|
+
out.append(text[index : end + 1])
|
|
26
|
+
index = end + 1
|
|
27
|
+
elif text.startswith("//", index):
|
|
28
|
+
end = text.find("\n", index)
|
|
29
|
+
end = length if end < 0 else end
|
|
30
|
+
out.append(" " * (end - index))
|
|
31
|
+
index = end
|
|
32
|
+
elif text.startswith("/*", index):
|
|
33
|
+
end = text.find("*/", index + 2)
|
|
34
|
+
end = length if end < 0 else end + 2
|
|
35
|
+
out.append("".join("\n" if c == "\n" else " " for c in text[index:end]))
|
|
36
|
+
index = end
|
|
37
|
+
else:
|
|
38
|
+
out.append(char)
|
|
39
|
+
index += 1
|
|
40
|
+
return _drop_trailing_commas("".join(out))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _drop_trailing_commas(text: str) -> str:
|
|
44
|
+
"""Drop commas before a closing bracket, outside strings."""
|
|
45
|
+
parts = re.split(r'("(?:[^"\\]|\\.)*")', text) # odd parts are strings
|
|
46
|
+
return "".join(
|
|
47
|
+
part if index % 2 else _TRAILING_COMMA.sub(r" \1", part) for index, part in enumerate(parts)
|
|
48
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""JavaScript and TypeScript source with comments and literals blanked out.
|
|
2
|
+
|
|
3
|
+
Nothing is evaluated. :func:`blank_code` returns the source with the *contents* of
|
|
4
|
+
comments, strings, template literals and regular expressions replaced by spaces (quotes
|
|
5
|
+
and newlines kept, so positions and line numbers do not move). Patterns matched against
|
|
6
|
+
the result only see code: ``"server: { port: 9999 }"`` inside a string or a commented-out
|
|
7
|
+
block no longer looks like a setting. Literal values are then read from the original
|
|
8
|
+
text at the same position.
|
|
9
|
+
|
|
10
|
+
It is a lexer, not a parser: a ``/`` counts as the start of a regular expression when
|
|
11
|
+
the previous code character cannot end an operand, which is right for configuration
|
|
12
|
+
files but not for every program.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
_OPERAND_END = frozenset(")]}") # after these (and names, numbers, literals) "/" divides
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def blank_code(source: str) -> str:
|
|
21
|
+
"""``source`` with comment and literal contents replaced by spaces."""
|
|
22
|
+
out = list(source)
|
|
23
|
+
_code(source, 0, out, nested=False)
|
|
24
|
+
return "".join(out)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _blank(out: list[str], start: int, end: int) -> None:
|
|
28
|
+
for index in range(start, min(end, len(out))):
|
|
29
|
+
if out[index] != "\n":
|
|
30
|
+
out[index] = " "
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _code(source: str, index: int, out: list[str], *, nested: bool) -> int:
|
|
34
|
+
"""Scan code from ``index``; inside a template ``${…}`` stop after its ``}``."""
|
|
35
|
+
length = len(source)
|
|
36
|
+
depth = 0
|
|
37
|
+
previous = "" # the last code character, to tell a regex from a division
|
|
38
|
+
while index < length:
|
|
39
|
+
char = source[index]
|
|
40
|
+
pair = source[index : index + 2]
|
|
41
|
+
if pair == "//":
|
|
42
|
+
end = source.find("\n", index)
|
|
43
|
+
end = length if end < 0 else end
|
|
44
|
+
_blank(out, index, end)
|
|
45
|
+
index = end
|
|
46
|
+
elif pair == "/*":
|
|
47
|
+
end = source.find("*/", index + 2)
|
|
48
|
+
end = length if end < 0 else end + 2
|
|
49
|
+
_blank(out, index, end)
|
|
50
|
+
index = end
|
|
51
|
+
elif char in "'\"":
|
|
52
|
+
closing, end = _string_end(source, index, char)
|
|
53
|
+
_blank(out, index + 1, closing)
|
|
54
|
+
index, previous = end, "a"
|
|
55
|
+
elif char == "`":
|
|
56
|
+
index, previous = _template(source, index, out), "a"
|
|
57
|
+
elif char == "/" and not _ends_operand(previous):
|
|
58
|
+
regex = _regex_end(source, index)
|
|
59
|
+
if regex is None: # not a regex after all: a lone slash
|
|
60
|
+
index, previous = index + 1, char
|
|
61
|
+
continue
|
|
62
|
+
_blank(out, index + 1, regex[0])
|
|
63
|
+
index, previous = regex[1], "a"
|
|
64
|
+
else:
|
|
65
|
+
if nested and char == "{":
|
|
66
|
+
depth += 1
|
|
67
|
+
elif nested and char == "}":
|
|
68
|
+
if depth == 0:
|
|
69
|
+
return index + 1
|
|
70
|
+
depth -= 1
|
|
71
|
+
if not char.isspace():
|
|
72
|
+
previous = char
|
|
73
|
+
index += 1
|
|
74
|
+
return index
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ends_operand(previous: str) -> bool:
|
|
78
|
+
"""Whether a ``/`` after this character divides rather than starts a regex."""
|
|
79
|
+
return previous.isalnum() or previous in "_$" or previous in _OPERAND_END
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _string_end(source: str, start: int, quote: str) -> tuple[int, int]:
|
|
83
|
+
"""Where the contents end and where code resumes: after the closing quote, or at the
|
|
84
|
+
end of the line when the string is never closed."""
|
|
85
|
+
index = start + 1
|
|
86
|
+
while index < len(source):
|
|
87
|
+
char = source[index]
|
|
88
|
+
if char == "\\":
|
|
89
|
+
index += 2
|
|
90
|
+
continue
|
|
91
|
+
if char == quote:
|
|
92
|
+
return index, index + 1
|
|
93
|
+
if char == "\n":
|
|
94
|
+
return index, index
|
|
95
|
+
index += 1
|
|
96
|
+
return len(source), len(source)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _template(source: str, start: int, out: list[str]) -> int:
|
|
100
|
+
"""Blank a template literal's text, scan its ``${…}`` parts as code."""
|
|
101
|
+
index = start + 1
|
|
102
|
+
text_from = index
|
|
103
|
+
while index < len(source):
|
|
104
|
+
char = source[index]
|
|
105
|
+
if char == "\\":
|
|
106
|
+
index += 2
|
|
107
|
+
elif char == "`":
|
|
108
|
+
_blank(out, text_from, index)
|
|
109
|
+
return index + 1
|
|
110
|
+
elif source.startswith("${", index):
|
|
111
|
+
_blank(out, text_from, index)
|
|
112
|
+
index = _code(source, index + 2, out, nested=True)
|
|
113
|
+
text_from = index
|
|
114
|
+
else:
|
|
115
|
+
index += 1
|
|
116
|
+
_blank(out, text_from, len(source))
|
|
117
|
+
return len(source)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _regex_end(source: str, start: int) -> tuple[int, int] | None:
|
|
121
|
+
"""The closing ``/`` of a regex literal and the index after its flags, or ``None``
|
|
122
|
+
when the line ends first."""
|
|
123
|
+
index = start + 1
|
|
124
|
+
in_class = False
|
|
125
|
+
while index < len(source):
|
|
126
|
+
char = source[index]
|
|
127
|
+
if char == "\\":
|
|
128
|
+
index += 2
|
|
129
|
+
continue
|
|
130
|
+
if char == "\n":
|
|
131
|
+
return None
|
|
132
|
+
if char == "[":
|
|
133
|
+
in_class = True
|
|
134
|
+
elif char == "]":
|
|
135
|
+
in_class = False
|
|
136
|
+
elif char == "/" and not in_class:
|
|
137
|
+
end = index + 1
|
|
138
|
+
while end < len(source) and source[end].isalpha():
|
|
139
|
+
end += 1
|
|
140
|
+
return index, end
|
|
141
|
+
index += 1
|
|
142
|
+
return None
|
worktree_shield/plan.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""A plan of changes that would resolve the collisions; worktree-shield applies none.
|
|
2
|
+
|
|
3
|
+
For every finding that needs attention, the current worktree (or the first one) keeps
|
|
4
|
+
its value and each other worktree gets its own: the next port that no worktree
|
|
5
|
+
configures and no process holds, a database name with the worktree's name appended,
|
|
6
|
+
the worktree's name as its Compose project. A value read from a committed ``.env``
|
|
7
|
+
template is overridden in that worktree's own ``.env``, since editing the template
|
|
8
|
+
would change it for every branch; values in other files are changed in place.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import PurePosixPath
|
|
17
|
+
|
|
18
|
+
from worktree_shield.collectors.envfiles import TEMPLATE_FILES
|
|
19
|
+
from worktree_shield.models import Evidence, Finding, ResourceKind, Snapshot, Status
|
|
20
|
+
from worktree_shield.rules.common import name_of, port_of
|
|
21
|
+
|
|
22
|
+
_PROBLEMS = (Status.FAIL, Status.WARN)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Suggestion:
|
|
27
|
+
"""In ``worktree``, at ``where`` (a file, with a line when known), make ``change``."""
|
|
28
|
+
|
|
29
|
+
worktree: str
|
|
30
|
+
where: str
|
|
31
|
+
change: str
|
|
32
|
+
rule: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_plan(snapshot: Snapshot) -> list[Suggestion]:
|
|
36
|
+
"""The suggested changes, in finding order; empty when nothing needs attention."""
|
|
37
|
+
repository = snapshot.repository
|
|
38
|
+
if repository is None:
|
|
39
|
+
return []
|
|
40
|
+
order = [w.path for w in repository.worktrees]
|
|
41
|
+
current = repository.current
|
|
42
|
+
taken = {p for c in snapshot.claims if (p := port_of(c.key)) is not None}
|
|
43
|
+
if snapshot.runtime is not None:
|
|
44
|
+
taken |= {listener.port for listener in snapshot.runtime.unattributed}
|
|
45
|
+
planner = _Planner(order, current, taken)
|
|
46
|
+
suggestions: list[Suggestion] = []
|
|
47
|
+
for finding in snapshot.findings:
|
|
48
|
+
if finding.status in _PROBLEMS and finding.caused_by is None:
|
|
49
|
+
suggestions += planner.suggest(finding)
|
|
50
|
+
return suggestions
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Planner:
|
|
54
|
+
def __init__(self, order: list[str], current: str | None, taken: set[int]) -> None:
|
|
55
|
+
self.rank = {worktree: index for index, worktree in enumerate(order)}
|
|
56
|
+
self.current, self.taken = current, taken
|
|
57
|
+
self.assigned: dict[tuple[str, int], int] = {} # (worktree, old port) -> new port
|
|
58
|
+
self.seen: set[tuple[str, str, str]] = set()
|
|
59
|
+
|
|
60
|
+
def movers(self, finding: Finding) -> list[str]:
|
|
61
|
+
"""The worktrees that change: all but the one that keeps the value, by path, so
|
|
62
|
+
the result does not depend on the order Git lists them in."""
|
|
63
|
+
involved = sorted(finding.worktrees, key=lambda w: self.rank.get(w, len(self.rank)))
|
|
64
|
+
if finding.id == "WTS-PORT-002" and not finding.summary.endswith("another worktree"):
|
|
65
|
+
return sorted(involved) # a process outside the worktrees holds it: all move
|
|
66
|
+
keeper = self.current if self.current in involved else involved[0]
|
|
67
|
+
return sorted(w for w in involved if w != keeper)
|
|
68
|
+
|
|
69
|
+
def new_port(self, worktree: str, old: int) -> int:
|
|
70
|
+
"""One new port per worktree and old port, however many findings name it."""
|
|
71
|
+
if (worktree, old) not in self.assigned:
|
|
72
|
+
port = old + 1
|
|
73
|
+
while port in self.taken:
|
|
74
|
+
port += 1
|
|
75
|
+
self.taken.add(port)
|
|
76
|
+
self.assigned[(worktree, old)] = port
|
|
77
|
+
return self.assigned[(worktree, old)]
|
|
78
|
+
|
|
79
|
+
def suggest(self, finding: Finding) -> Iterator[Suggestion]:
|
|
80
|
+
for suggestion in self._suggest(finding):
|
|
81
|
+
key = (suggestion.worktree, suggestion.where, suggestion.change)
|
|
82
|
+
if key not in self.seen:
|
|
83
|
+
self.seen.add(key)
|
|
84
|
+
yield suggestion
|
|
85
|
+
|
|
86
|
+
def _suggest(self, finding: Finding) -> Iterator[Suggestion]:
|
|
87
|
+
kind = _KIND_OF.get(finding.id)
|
|
88
|
+
if kind is None or finding.resource_key is None:
|
|
89
|
+
return
|
|
90
|
+
key = finding.resource_key
|
|
91
|
+
for position, worktree in enumerate(self.movers(finding), start=1):
|
|
92
|
+
items = [
|
|
93
|
+
e for e in finding.evidence if e.key == worktree and not e.source.startswith("pid ")
|
|
94
|
+
]
|
|
95
|
+
if kind is ResourceKind.LISTEN_PORT:
|
|
96
|
+
old = port_of(key) or 0
|
|
97
|
+
new = self.new_port(worktree, old)
|
|
98
|
+
for item in items:
|
|
99
|
+
yield _port(finding, worktree, item, old, new)
|
|
100
|
+
elif kind is ResourceKind.DATABASE:
|
|
101
|
+
new_name, database = _database(key, worktree, position)
|
|
102
|
+
for item in items:
|
|
103
|
+
where = _env_override(item) or item.source
|
|
104
|
+
change = f"{item.value} naming the database {new_name} instead of {database}"
|
|
105
|
+
yield Suggestion(worktree, where, change, finding.id)
|
|
106
|
+
elif kind is ResourceKind.COMPOSE_PROJECT:
|
|
107
|
+
for item in items[:1]:
|
|
108
|
+
folder = PurePosixPath(item.source.split(":", 1)[0]).parent
|
|
109
|
+
change = f"COMPOSE_PROJECT_NAME={_slug(worktree, '-')}"
|
|
110
|
+
yield Suggestion(worktree, (folder / ".env").as_posix(), change, finding.id)
|
|
111
|
+
elif kind is ResourceKind.CONTAINER_NAME:
|
|
112
|
+
change = (
|
|
113
|
+
f"container_name: remove it, or use ${{COMPOSE_PROJECT_NAME}}-{name_of(key)}"
|
|
114
|
+
)
|
|
115
|
+
for item in items:
|
|
116
|
+
yield Suggestion(worktree, item.source, change, finding.id)
|
|
117
|
+
else: # volumes and paths: a name of their own
|
|
118
|
+
yield from _renamed(finding, worktree, items)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _port(finding: Finding, worktree: str, item: Evidence, old: int, new: int) -> Suggestion:
|
|
122
|
+
detail = item.value or ""
|
|
123
|
+
override = _env_override(item)
|
|
124
|
+
if override is not None: # PORT=5173 from .env.example: PORT=5174 in .env
|
|
125
|
+
variable = detail.split("=", 1)[0]
|
|
126
|
+
return Suggestion(worktree, override, f"{variable}={new}", finding.id)
|
|
127
|
+
if detail.startswith(("scripts.", "tool.")) and ": " in detail:
|
|
128
|
+
detail = detail.split(": ", 1)[1] # the fragment, not the task name
|
|
129
|
+
# The first number is the host port: in "5432:5432" the container's stays.
|
|
130
|
+
changed = detail.replace(str(old), str(new), 1) if str(old) in detail else f"port {new}"
|
|
131
|
+
return Suggestion(worktree, item.source, f"{detail} → {changed}", finding.id)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _database(key: str, worktree: str, position: int) -> tuple[str, str]:
|
|
135
|
+
"""(new name, old name); Redis databases are numbers, so the next free ones."""
|
|
136
|
+
engine = name_of(key).split("/", 1)[0]
|
|
137
|
+
database = name_of(key).rsplit("/", 1)[-1]
|
|
138
|
+
if engine == "redis" and database.isdigit():
|
|
139
|
+
return str(int(database) + position), database
|
|
140
|
+
return f"{database}_{_slug(worktree, '_')}", database
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _renamed(finding: Finding, worktree: str, items: list[Evidence]) -> Iterator[Suggestion]:
|
|
144
|
+
name = name_of(finding.resource_key or "")
|
|
145
|
+
renamed = f"{name}-{_slug(worktree, '-')}"
|
|
146
|
+
for item in items:
|
|
147
|
+
override = _env_override(item)
|
|
148
|
+
if override is not None: # UPLOAD_DIR=… from a template
|
|
149
|
+
variable = (item.value or "").split("=", 1)[0]
|
|
150
|
+
yield Suggestion(worktree, override, f"{variable}={renamed}", finding.id)
|
|
151
|
+
else:
|
|
152
|
+
yield Suggestion(worktree, item.source, f"{name} → {renamed}", finding.id)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_KIND_OF = {
|
|
156
|
+
"WTS-PORT-001": ResourceKind.LISTEN_PORT,
|
|
157
|
+
"WTS-PORT-002": ResourceKind.LISTEN_PORT,
|
|
158
|
+
"WTS-DB-001": ResourceKind.DATABASE,
|
|
159
|
+
"WTS-DOCKER-001": ResourceKind.COMPOSE_PROJECT,
|
|
160
|
+
"WTS-DOCKER-002": ResourceKind.CONTAINER_NAME,
|
|
161
|
+
"WTS-DOCKER-003": ResourceKind.VOLUME,
|
|
162
|
+
"WTS-PATH-001": ResourceKind.ABSOLUTE_PATH,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _env_override(item: Evidence) -> str | None:
|
|
167
|
+
"""``apps/web/.env`` for a claim read from ``apps/web/.env.example``."""
|
|
168
|
+
path = PurePosixPath(item.source.split(":", 1)[0])
|
|
169
|
+
return (path.parent / ".env").as_posix() if path.name in TEMPLATE_FILES else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _slug(worktree: str, separator: str) -> str:
|
|
173
|
+
"""The worktree's directory name, as an identifier: ``app-login`` / ``app_login``."""
|
|
174
|
+
name = PurePosixPath(worktree.replace("\\", "/")).name.lower()
|
|
175
|
+
return re.sub(r"[^a-z0-9]+", separator, name).strip(separator) or "worktree"
|