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,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
@@ -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"