max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_013wvegQEzB5piwPowYQ3ZbV
1 file changed,
+70 insertions,
-1 deletion
| @@ -82,9 +82,43 @@ | |||
| 82 | 82 | dials.setdefault("LANGS", "") | |
| 83 | 83 | dials.setdefault("DB", "none") | |
| 84 | 84 | dials.setdefault("GUI", "none") | |
| 85 | + | dials.setdefault("TRIM", "unused") | |
| 85 | 86 | return dials | |
| 86 | 87 | ||
| 87 | 88 | ||
| 89 | + | def eval_condition(frag, dials): | |
| 90 | + | """One `if` fragment against the recipe's dials: True, False, or None. | |
| 91 | + | ||
| 92 | + | Only the shape the Containerfile uses is read -- `[ "$VAR" = value ]` and its | |
| 93 | + | `!=`, joined by `&&`. Anything else returns None and is treated as taken, | |
| 94 | + | so a new guard shape costs a wide probe rather than a silent one. | |
| 95 | + | """ | |
| 96 | + | if "||" in frag: | |
| 97 | + | return None | |
| 98 | + | tests = re.findall(r'\[\s*"\$(\w+)"\s*(=|!=)\s*([A-Za-z0-9._+-]+)\s*\]', frag) | |
| 99 | + | if not tests: | |
| 100 | + | return None | |
| 101 | + | # Every `[ ... ]` in the fragment has to be one this understands, or the | |
| 102 | + | # conjunction is being evaluated against half its terms. | |
| 103 | + | if len(tests) != frag.count("[ "): | |
| 104 | + | return None | |
| 105 | + | for var, op, want in tests: | |
| 106 | + | have = dials.get(var, "") | |
| 107 | + | if (have == want) != (op == "="): | |
| 108 | + | return False | |
| 109 | + | return True | |
| 110 | + | ||
| 111 | + | ||
| 112 | + | def branch_taken(if_stack): | |
| 113 | + | """Whether the fragment's enclosing if/else frames all lead here.""" | |
| 114 | + | for cond, in_else in if_stack: | |
| 115 | + | if cond is None: | |
| 116 | + | continue | |
| 117 | + | if cond == in_else: # False and not in else, or True and in else | |
| 118 | + | return False | |
| 119 | + | return True | |
| 120 | + | ||
| 121 | + | ||
| 88 | 122 | def install_sites(dials): | |
| 89 | 123 | """Package names this mint installs, with the dial arm that guards each. | |
| 90 | 124 | ||
| @@ -101,6 +135,15 @@ | |||
| 101 | 135 | in force, so a package inside `postgres16)` is dropped when the recipe says | |
| 102 | 136 | DB=none. Approximating this instead would report packages the mint does not | |
| 103 | 137 | install, and a false failure is how an instrument stops being read. | |
| 138 | + | ||
| 139 | + | `if [ "$VAR" = value ]` is read the same way, and it is not a nicety: the | |
| 140 | + | profile split is written as an if/else rather than a case, so a parser that | |
| 141 | + | saw only `case` attributed every client-only package to the base. Measured | |
| 142 | + | 2026-09-04 on astra, the first server recipe anyone ran this against: its | |
| 143 | + | package set came back one package short of fw13's (firefox), carrying sway, | |
| 144 | + | greetd, cups and fontconfig, and the probe then failed bootc's /var lint on | |
| 145 | + | the client tmpfiles file a server mint correctly drops. A finding against a | |
| 146 | + | mint that would pass, which is the failure mode this file exists to avoid. | |
| 104 | 147 | """ | |
| 105 | 148 | logical, buf = [], "" | |
| 106 | 149 | for raw in open(os.path.join(REPO, "Containerfile")): | |
| @@ -118,10 +161,36 @@ | |||
| 118 | 161 | pkgs = {} | |
| 119 | 162 | for line in logical: | |
| 120 | 163 | case_stack, arm = [], None | |
| 164 | + | # if/else frames, innermost last. Each is [condition, in_else], where a | |
| 165 | + | # condition of None means a shape this does not read -- those count as | |
| 166 | + | # taken, because dropping packages on an unrecognised guard would hide | |
| 167 | + | # real findings, which is the one failure worse than reporting extra. | |
| 168 | + | if_stack = [] | |
| 121 | 169 | for frag in re.split(r'[;]', line): | |
| 122 | 170 | f = frag.strip() | |
| 123 | 171 | if not f: | |
| 124 | 172 | continue | |
| 173 | + | # A fragment carries whatever keywords preceded it with no | |
| 174 | + | # semicolon between them: `RUN if [ ... ]` is one, and so is | |
| 175 | + | # `then dnf install ...`. Anchoring on the bare keyword found | |
| 176 | + | # nothing, which is how PROFILE=server kept every client package. | |
| 177 | + | kw = re.sub(r'^((RUN|then|do|else)\s+)+', '', f) | |
| 178 | + | if re.match(r'^(el)?if\s', kw): | |
| 179 | + | cond = eval_condition(kw, dials) | |
| 180 | + | if kw.startswith("elif"): | |
| 181 | + | if if_stack: | |
| 182 | + | if_stack[-1] = [cond, False] | |
| 183 | + | else: | |
| 184 | + | if_stack.append([cond, False]) | |
| 185 | + | continue | |
| 186 | + | if re.match(r'^else\b', f) and if_stack: | |
| 187 | + | if_stack[-1][1] = True | |
| 188 | + | # `else` and the command it guards can share a fragment, so | |
| 189 | + | # this falls through rather than continuing. | |
| 190 | + | if re.match(r'^fi\b', f): | |
| 191 | + | if if_stack: | |
| 192 | + | if_stack.pop() | |
| 193 | + | continue | |
| 125 | 194 | m = re.search(r'case\s+"\$(\w+)"\s+in', f) | |
| 126 | 195 | if m: | |
| 127 | 196 | case_stack.append(m.group(1)) | |
| @@ -147,7 +216,7 @@ | |||
| 147 | 216 | # which is how `above`, `moved`, `layer` and `setting` ended up in a | |
| 148 | 217 | # probe's install list. An odd number of quotes before the match | |
| 149 | 218 | # means the match is inside a string. | |
| 150 | - | if idx >= 0 and f[:idx].count('"') % 2 == 0: | |
| 219 | + | if idx >= 0 and f[:idx].count('"') % 2 == 0 and branch_taken(if_stack): | |
| 151 | 220 | tail = f[idx + len("dnf install"):] | |
| 152 | 221 | tail = re.split(r'&&|\|\|', tail)[0] | |
| 153 | 222 | guard = "base" if not case_stack else "%s=%s" % (case_stack[-1], arm) |