template_press 3.2.0__tar.gz → 3.3.0__tar.gz

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 (26) hide show
  1. {template_press-3.2.0 → template_press-3.3.0}/PKG-INFO +1 -1
  2. {template_press-3.2.0 → template_press-3.3.0}/pyproject.toml +4 -2
  3. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/cli.py +98 -8
  4. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/config.py +22 -1
  5. template_press-3.3.0/src/template_press/rebrand/doctor.py +336 -0
  6. template_press-3.3.0/src/template_press/rebrand/engine.py +1025 -0
  7. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/identity.py +76 -2
  8. template_press-3.3.0/src/template_press/rebrand/rules.py +311 -0
  9. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/synthesize.py +49 -2
  10. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/verifier.py +130 -8
  11. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/verify_cli.py +77 -6
  12. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/verify_config.py +7 -4
  13. template_press-3.2.0/src/template_press/rebrand/doctor.py +0 -151
  14. template_press-3.2.0/src/template_press/rebrand/engine.py +0 -558
  15. template_press-3.2.0/src/template_press/rebrand/rules.py +0 -90
  16. {template_press-3.2.0 → template_press-3.3.0}/README.md +0 -0
  17. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/__init__.py +0 -0
  18. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/press_cli.py +0 -0
  19. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/py.typed +0 -0
  20. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/__init__.py +0 -0
  21. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/discovery.py +0 -0
  22. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/ignores.py +0 -0
  23. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/matcher.py +0 -0
  24. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/receipt.py +0 -0
  25. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/safety.py +0 -0
  26. {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/sandbox.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: template_press
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Standalone rebrand utility — press a new identity onto an external target repo
5
5
  Author: Steve Morin
6
6
  Author-email: Steve Morin <steve.morin@gmail.com>
@@ -20,7 +20,7 @@
20
20
  [project]
21
21
  name = "template_press"
22
22
  # ITM-070 — static version (ADR-06 cutover). release-please bumps this.
23
- version = "3.2.0"
23
+ version = "3.3.0"
24
24
  description = "Standalone rebrand utility — press a new identity onto an external target repo"
25
25
  readme = "README.md"
26
26
  requires-python = ">=3.12"
@@ -216,10 +216,12 @@ strict_markers = true
216
216
  [tool.codespell]
217
217
  skip = ".git,.venv,build,dist,node_modules,uv.lock,bun.lock,*.lock,docs/_build,*.svg,*.cast,.ruff_cache,.pytest_cache,.mypy_cache,.ty_cache,*.log"
218
218
  # *.log: raw run output, not prose.
219
- ignore-words-list = "ans,porjects"
219
+ ignore-words-list = "ans,porjects,replcae"
220
220
  # ans: variable name used in prompt-handling code; codespell wants to "fix"
221
221
  # it to "and". Whitelist to keep the short variable name.
222
222
  # porjects: intentional typo — the did-you-mean example in cli/groups.py and
223
223
  # its test ("press porjects" must suggest "projects").
224
+ # replcae: intentional typo — the unknown-root-key regression test/message
225
+ # for `[[replcae]]` (rules.py F5a).
224
226
  check-filenames = true
225
227
  check-hidden = false
@@ -27,9 +27,16 @@ from template_press.rebrand.engine import (
27
27
  ApplyReport,
28
28
  apply,
29
29
  build_plan,
30
+ rendered_replace_rules,
30
31
  stray_press_dirs,
31
32
  )
32
- from template_press.rebrand.identity import Identity, ValidationError, token_occurs
33
+ from template_press.rebrand.identity import (
34
+ DISPLAY_FORM_NAMES,
35
+ Identity,
36
+ ValidationError,
37
+ display_forms,
38
+ token_occurs,
39
+ )
33
40
  from template_press.rebrand.receipt import read_receipt, write_receipt
34
41
  from template_press.rebrand.rules import DEFAULT_RULES, Rules, load_rules
35
42
  from template_press.rebrand.safety import (
@@ -130,7 +137,31 @@ def _resolve_source(
130
137
  return source, write_pending
131
138
 
132
139
 
133
- def _collisions(source: Identity, dest: Identity) -> list[str]:
140
+ def _expand_display_forms(values: dict[str, str]) -> dict[str, str]:
141
+ """Replace a raw ``display_name`` entry with its per-form expansions.
142
+
143
+ Mirrors ``replacement_pairs``' display-form handling (engine.py):
144
+ runtime pair tags are ``display_name_spaced/pascal/camel``, never bare
145
+ ``display_name``, so comparing raw dict values alone can miss a derived
146
+ form that embeds a changed source token. Uses the full
147
+ ``DISPLAY_FORM_NAMES`` set (not a rules-configured subset) — a subset
148
+ only narrows what gets REWRITTEN; this preflight stays conservative and
149
+ checks every form regardless.
150
+ """
151
+ if "display_name" not in values:
152
+ return values
153
+ expanded = {k: v for k, v in values.items() if k != "display_name"}
154
+ forms = display_forms(values["display_name"])
155
+ for form in DISPLAY_FORM_NAMES:
156
+ expanded[f"display_name_{form}"] = forms[form]
157
+ return expanded
158
+
159
+
160
+ def _collisions(
161
+ source: Identity,
162
+ dest: Identity,
163
+ substring_fields: frozenset[str] = frozenset(),
164
+ ) -> list[str]:
134
165
  """Destination values that embed a CHANGED source token.
135
166
 
136
167
  Sequential substitution would re-rewrite such output (old app name
@@ -138,13 +169,34 @@ def _collisions(source: Identity, dest: Identity) -> list[str]:
138
169
  would flag correct output as a leak (press → press_two). Refusing up
139
170
  front with guidance beats either silent corruption or a permanent
140
171
  verification failure.
172
+
173
+ Both identities are expanded the same way ``replacement_pairs`` expands
174
+ them (Fix F3): a raw ``display_name`` entry is replaced by its exact
175
+ per-form values, so a destination display name whose DERIVED form (e.g.
176
+ the camel form of "Plbp" is "plbp") embeds a changed source token is
177
+ caught even though the raw display name never contains it verbatim.
178
+
179
+ ``substring_fields`` (Fix F4) — the target's ``[rules]
180
+ substring_rewrite_fields`` — mirrors what the engine will actually do to
181
+ a changed field opted into substring mode: it rewrites that field
182
+ SUBSTRING-wide, with no word-boundary guard, so a destination value that
183
+ embeds the source token WITHOUT a boundary (e.g. dest repo_name
184
+ "myfoo-tools" embedding source app_name "foo") is just as much a
185
+ collision as a boundary-guarded one — checked via plain ``in`` instead of
186
+ the boundary-guarded ``token_occurs``.
141
187
  """
142
188
  out: list[str] = []
143
- src, dst = source.as_dict(), dest.as_dict()
144
- changed = {f: v for f, v in src.items() if v != dst[f]}
189
+ src = _expand_display_forms(source.as_dict())
190
+ dst = _expand_display_forms(dest.as_dict())
191
+ changed = {f: v for f, v in src.items() if v != dst.get(f)}
145
192
  for dest_field, dest_value in dst.items():
146
193
  for src_field, src_value in changed.items():
147
- if token_occurs(dest_value, src_field, src_value):
194
+ hit = (
195
+ src_value in dest_value
196
+ if src_field in substring_fields
197
+ else token_occurs(dest_value, src_field, src_value)
198
+ )
199
+ if hit:
148
200
  out.append(
149
201
  f"destination {dest_field}={dest_value!r} contains the "
150
202
  f"source {src_field} token {src_value!r}"
@@ -152,6 +204,24 @@ def _collisions(source: Identity, dest: Identity) -> list[str]:
152
204
  return out
153
205
 
154
206
 
207
+ def display_name_problem(source: Identity, dest: Identity) -> str | None:
208
+ """Half-specified display identity is refused (codesign sec-06).
209
+
210
+ The press knows what to erase but not what to write — proceeding would
211
+ ship a half-rebrand where every prose mention keeps the old product
212
+ name. The reverse direction is harmless: nothing to rewrite, and the
213
+ post-apply source-config write records the new display name.
214
+ """
215
+ if source.display_name is not None and dest.display_name is None:
216
+ return (
217
+ f"source-config declares display_name "
218
+ f"({source.display_name!r}) but the answers file does not — "
219
+ f"add display_name to [answers]; press cannot know the new "
220
+ f"display name"
221
+ )
222
+ return None
223
+
224
+
155
225
  def main(argv: list[str] | None = None) -> int:
156
226
  parser = argparse.ArgumentParser(prog="press rebrand", description=__doc__)
157
227
  parser.add_argument("--target", type=Path, required=True)
@@ -186,11 +256,23 @@ def main(argv: list[str] | None = None) -> int:
186
256
  return _fail("--config ANSWERS.toml is required")
187
257
  dest = load_answers(args.config)
188
258
 
259
+ display_problem = display_name_problem(source, dest)
260
+ if display_problem is not None:
261
+ return _fail(display_problem)
262
+
189
263
  if source == dest:
190
264
  return _fail(
191
265
  "source and destination identities are identical — nothing to press"
192
266
  )
193
- collisions = _collisions(source, dest)
267
+ # Loaded before the collision preflight (Fix F4): substring_rewrite_fields
268
+ # changes what counts as a collision (a boundary-free embedded token is
269
+ # only a problem for a field the engine will actually rewrite
270
+ # substring-wide) — pure reading, no side effect, so moving it earlier
271
+ # is safe.
272
+ rules = load_rules(target)
273
+ collisions = _collisions(
274
+ source, dest, substring_fields=rules.substring_rewrite_fields
275
+ )
194
276
  if collisions:
195
277
  print(
196
278
  "error: destination identity embeds source tokens — a single "
@@ -202,7 +284,6 @@ def main(argv: list[str] | None = None) -> int:
202
284
  print(f" {c}", file=sys.stderr)
203
285
  return 2
204
286
 
205
- rules = load_rules(target)
206
287
  plan = build_plan(target, source, dest, rules)
207
288
  print(plan.render())
208
289
  strays = stray_press_dirs(target)
@@ -322,7 +403,16 @@ def _press(
322
403
  regenerate=rules.regenerate,
323
404
  verify_ignore=rules.verify_ignore,
324
405
  )
325
- leaks = find_leaks(target, source, doctor_rules, dest=dest)
406
+ leaks = find_leaks(
407
+ target,
408
+ source,
409
+ doctor_rules,
410
+ dest=dest,
411
+ display_form_names=rules.display_forms,
412
+ substring_fields=rules.substring_rewrite_fields,
413
+ rendered_rules=rendered_replace_rules(rules, source, dest),
414
+ renamed=report.renamed,
415
+ )
326
416
  if leaks:
327
417
  print(render_leak_report(leaks), file=sys.stderr)
328
418
  print(report.render(), file=sys.stderr)
@@ -12,7 +12,12 @@ import tomllib
12
12
  from pathlib import Path
13
13
 
14
14
  from template_press.rebrand.engine import ROOT_CONTROL
15
- from template_press.rebrand.identity import Identity, ValidationError
15
+ from template_press.rebrand.identity import (
16
+ OPTIONAL_FIELDS,
17
+ REQUIRED_FIELDS,
18
+ Identity,
19
+ ValidationError,
20
+ )
16
21
  from template_press.rebrand.safety import ContainmentError, assert_under_root
17
22
 
18
23
  SOURCE_CONFIG_REL = Path("press") / "press-source.toml"
@@ -64,6 +69,22 @@ def load_identity_toml(path: Path, table: str) -> Identity:
64
69
  section = data.get(table)
65
70
  if not isinstance(section, dict):
66
71
  raise ValidationError(f"{path}: missing [{table}] table")
72
+ # F3: an unknown key (typo'd optional field, e.g. `display_nam`) must
73
+ # fail loud — the dict-comprehension below silently drops any key it
74
+ # doesn't recognize, so a typo would otherwise pass through as if the
75
+ # field were simply absent, and press proceeds none the wiser.
76
+ known = frozenset(REQUIRED_FIELDS) | frozenset(OPTIONAL_FIELDS)
77
+ unknown = set(section) - known
78
+ if unknown:
79
+ raise ValidationError(
80
+ f"{path}: [{table}] unknown key(s): {', '.join(sorted(unknown))}"
81
+ )
82
+ for key in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS):
83
+ if key in section and not isinstance(section[key], str):
84
+ raise ValidationError(
85
+ f"{path}: [{table}] {key} must be a string, got "
86
+ f"{type(section[key]).__name__}"
87
+ )
67
88
  identity = Identity.from_mapping(
68
89
  {k: v for k, v in section.items() if isinstance(v, str)}
69
90
  )
@@ -0,0 +1,336 @@
1
+ """No-leak verification: the gate between apply() and the receipt (EMP-01).
2
+
3
+ A rebrand that leaves ANY source-identity token behind — in file content or
4
+ in a path name — is a failed rebrand. The CLI must exit non-zero and write
5
+ no receipt. Port of init_doctor.check_no_identity_leftover, generalized to
6
+ (target, identity, rules) and extended with path-name checking.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from collections.abc import Collection
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from template_press.rebrand.engine import (
17
+ ROOT_CONTROL,
18
+ _git_listed,
19
+ _gitlink_rels,
20
+ _is_excluded,
21
+ _is_root_press,
22
+ iter_target_files,
23
+ symlink_target_posix,
24
+ )
25
+ from template_press.rebrand.identity import (
26
+ DISPLAY_FORM_NAMES,
27
+ Identity,
28
+ display_forms,
29
+ occurs,
30
+ )
31
+ from template_press.rebrand.rules import ReplaceRule, Rules, rule_matches_path
32
+
33
+ PATH_FIELDS: tuple[str, ...] = (
34
+ "package_name",
35
+ "repo_name",
36
+ "app_name",
37
+ "app_name_upper",
38
+ )
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Leak:
43
+ path: str
44
+ field: str
45
+ value: str
46
+ where: str # "content" | "path" | "symlink" | "unverifiable"
47
+
48
+
49
+ def _read_for_scan(path: Path) -> str | None:
50
+ """Content for scanning; None for binary/symlink; OSError propagates.
51
+
52
+ Unlike the engine's lenient reader, the doctor must NOT silently skip an
53
+ unreadable file — a file it cannot scan is a file it cannot certify.
54
+ """
55
+ if path.is_symlink():
56
+ return None # content lives outside the target; the name is scanned
57
+ try:
58
+ return path.read_text(encoding="utf-8")
59
+ except UnicodeDecodeError:
60
+ return None # binary: the rewrite pass cannot alter it either
61
+
62
+
63
+ def _reverse_renamed_posix(posix: str, renamed: list[tuple[str, str]]) -> str:
64
+ """Undo every prefix rename recorded in ``renamed`` (new -> old), longest
65
+ new-prefix first, applied iteratively until no further substitution
66
+ fires (Fix F1).
67
+
68
+ A rename entry's NEW prefix substitutes back to its OLD prefix when
69
+ ``posix`` equals it exactly or starts with it plus ``"/"`` — mirroring
70
+ how ``_rename_pass_once``/``build_plan`` record ``(old_prefix,
71
+ new_prefix)`` collapsed to the shallowest differing ancestor per pass. A
72
+ path may have crossed several such ancestors across SEPARATE passes, so
73
+ this keeps substituting (bounded to ``len(renamed) + 1`` rounds — more
74
+ than enough for any chain of disjoint renames) rather than doing it once.
75
+ """
76
+ ordered = sorted(renamed, key=lambda pair: -len(pair[1]))
77
+ for _ in range(len(ordered) + 1):
78
+ for old, new in ordered:
79
+ if posix == new:
80
+ posix = old
81
+ break
82
+ if posix.startswith(new + "/"):
83
+ posix = old + posix[len(new) :]
84
+ break
85
+ else:
86
+ break
87
+ return posix
88
+
89
+
90
+ def _rule_scope_hits(
91
+ rule: ReplaceRule, posix: str, renamed: list[tuple[str, str]]
92
+ ) -> bool:
93
+ """Rule-literal scope match against BOTH the CURRENT path and its
94
+ PRE-rename original (Fix F1).
95
+
96
+ A token-rename pass can move a file's ancestor out from under a
97
+ ``files`` scope written against the source layout before the SAME
98
+ [[replace]] rule ever gets a chance to re-evaluate against it (the
99
+ rewrite-side scope migration is a documented 0008 limitation, left
100
+ unfixed there) — so scoping the doctor's rule-literal scan against the
101
+ current path alone would silently certify the missed rename. Over-flags
102
+ rather than misses: a hit on EITHER path counts.
103
+ """
104
+ return rule_matches_path(rule, posix) or rule_matches_path(
105
+ rule, _reverse_renamed_posix(posix, renamed)
106
+ )
107
+
108
+
109
+ def find_leaks(
110
+ target: Path,
111
+ source: Identity,
112
+ rules: Rules,
113
+ dest: Identity | None = None,
114
+ display_form_names: tuple[str, ...] = DISPLAY_FORM_NAMES,
115
+ substring_fields: Collection[str] = frozenset(),
116
+ rendered_rules: list[tuple[ReplaceRule, str, str]] | None = None,
117
+ renamed: list[tuple[str, str]] | None = None,
118
+ ) -> list[Leak]:
119
+ """Scan for surviving source-identity tokens.
120
+
121
+ When ``dest`` is given, only fields that actually CHANGED are scanned:
122
+ an unchanged field (same author across a rename) is not a leak — its
123
+ token legitimately remains everywhere. Without ``dest`` all fields are
124
+ scanned (full-rebrand semantics).
125
+
126
+ ``rendered_rules`` (rule, FROM, TO) triples from
127
+ ``engine.rendered_replace_rules`` — when a ``[[replace]]`` rule is the
128
+ ONLY matcher for a boundary-unmatched rendered form (e.g. an
129
+ underscore-glued ``_{app_name}_owned``), the ordinary field-based token
130
+ scan can miss a surviving FROM literal entirely (a containment-skipped
131
+ symlink retarget, or any other rewrite the engine could not perform).
132
+ Each rule is scanned scoped by what it was supposed to touch:
133
+ ``rule.content`` against file CONTENT (glob-scoped via
134
+ ``rule_matches_path`` against the file's own rel posix), and
135
+ ``rule.paths`` against PATH COMPONENTS (glob-scoped the same way,
136
+ mirroring ``_renamed_rel``) and SYMLINK text (scoped against the link
137
+ TARGET's normalized rel path, mirroring ``_retarget_symlinks``).
138
+
139
+ ``renamed`` (Fix F1) — ``ApplyReport.renamed`` (old_prefix, new_prefix)
140
+ pairs from every rename ``apply()`` actually executed — lets each
141
+ rule-literal scope check (``_rule_scope_hits``) recover a scanned
142
+ path/symlink-target's PRE-rename original before testing ``rule.files``:
143
+ a token-rename pass can move a rule-scoped path's ancestor out from under
144
+ its own ``files`` glob before that same rule ever gets to re-evaluate
145
+ against it, leaving a stale FROM literal that a current-path-only scope
146
+ check would miss entirely (a receipt/verify contradiction). Omitted
147
+ (``None``/empty), this degrades to the prior current-path-only behavior.
148
+
149
+ A third, GITLINK pass (Fix F2) scans every submodule index entry's PATH
150
+ COMPONENTS the same way — ``_gitlink_rels`` reads the index directly, so
151
+ an uninitialized/uncheckoutable submodule is still covered. Neither of
152
+ the two passes above ever reaches a gitlink: pass 1 filters on
153
+ ``Path.is_file()`` and pass 2 on ``Path.is_symlink()``, both False for a
154
+ submodule mount point with no working-tree materialization.
155
+ """
156
+ rendered_rules = rendered_rules or []
157
+ renamed = renamed or []
158
+ leaks: list[Leak] = []
159
+ fields = source.as_dict()
160
+ if "display_name" in fields:
161
+ # Expand into the exact per-form values so a surviving glued form
162
+ # (PyLaunchBlueprint) is a leak, not just the spaced original.
163
+ sf = display_forms(fields.pop("display_name"))
164
+ for form in display_form_names:
165
+ fields[f"display_name_{form}"] = sf[form]
166
+ if dest is not None:
167
+ dest_fields = dest.as_dict()
168
+ if "display_name" in dest_fields:
169
+ df = display_forms(dest_fields.pop("display_name"))
170
+ for form in display_form_names:
171
+ dest_fields[f"display_name_{form}"] = df[form]
172
+ fields = {k: v for k, v in fields.items() if dest_fields.get(k) != v}
173
+ # Path-component scans must cover display-form fields too (Fix 2): the
174
+ # doctor already expands display_name into its exact forms for the
175
+ # content/symlink scans above, but a leftover PyLaunchBlueprint/ dir
176
+ # would otherwise pass the path-component loops below, which iterated
177
+ # PATH_FIELDS only.
178
+ path_fields = (
179
+ *PATH_FIELDS,
180
+ *(f"display_name_{form}" for form in display_form_names),
181
+ )
182
+ covered_symlinks: set[str] = set()
183
+ for path in iter_target_files(target, rules):
184
+ rel = path.relative_to(target)
185
+ rel_posix = rel.as_posix()
186
+ try:
187
+ text = _read_for_scan(path)
188
+ except OSError:
189
+ leaks.append(Leak(rel_posix, "io", "unreadable", "unverifiable"))
190
+ text = None
191
+ if text is not None:
192
+ for field_name, value in fields.items():
193
+ if occurs(text, field_name, value, substring_fields):
194
+ leaks.append(Leak(rel_posix, field_name, value, "content"))
195
+ for rule, frm, _to in rendered_rules:
196
+ if (
197
+ rule.content
198
+ and _rule_scope_hits(rule, rel_posix, renamed)
199
+ and frm in text
200
+ ):
201
+ leaks.append(Leak(rel_posix, "replace_rule", frm, "content"))
202
+ if path.is_symlink():
203
+ # A symlink's bytes are its target string, not file content — an
204
+ # identity token embedded there would dangle/leak in a pressed fork.
205
+ covered_symlinks.add(rel_posix)
206
+ link = os.readlink(path)
207
+ for field_name, value in fields.items():
208
+ if occurs(link, field_name, value, substring_fields):
209
+ leaks.append(Leak(rel_posix, field_name, value, "symlink"))
210
+ # Rule scope for symlink text is the link's TARGET, normalized —
211
+ # mirroring _retarget_symlinks — never the link's own location.
212
+ link_target_posix = symlink_target_posix(rel, link)
213
+ for rule, frm, _to in rendered_rules:
214
+ if (
215
+ rule.paths
216
+ and _rule_scope_hits(rule, link_target_posix, renamed)
217
+ and frm in link
218
+ ):
219
+ leaks.append(Leak(rel_posix, "replace_rule", frm, "symlink"))
220
+ path_rules = [
221
+ (rule, frm)
222
+ for rule, frm, _to in rendered_rules
223
+ if rule.paths and _rule_scope_hits(rule, rel_posix, renamed)
224
+ ]
225
+ for i, component in enumerate(rel.parts):
226
+ if _is_root_press(rel, i):
227
+ continue
228
+ for field_name in path_fields:
229
+ value = fields.get(field_name)
230
+ if value is not None and occurs(
231
+ component, field_name, value, substring_fields
232
+ ):
233
+ leaks.append(Leak(rel_posix, field_name, value, "path"))
234
+ for _rule, frm in path_rules:
235
+ if frm in component:
236
+ leaks.append(Leak(rel_posix, "replace_rule", frm, "path"))
237
+ # DIRECTORY and DANGLING symlinks never reach the loop above:
238
+ # `iter_target_files` keeps only `is_file()` paths, and `is_file()` FOLLOWS
239
+ # the link — so a symlink to a dir (or to nothing) is dropped, and a source
240
+ # token embedded in its link string would slip the gate. Scan every
241
+ # git-listed symlink's `readlink` STRING here (never the destination),
242
+ # deduping the symlink-to-file links the loop already covered.
243
+ for rel in _git_listed(target):
244
+ rel_posix = rel.as_posix()
245
+ if rel_posix in covered_symlinks:
246
+ continue
247
+ path = target / rel
248
+ if not path.is_symlink():
249
+ continue
250
+ # The NAME of a dir/dangling symlink is never scanned by the main loop
251
+ # (iter_target_files drops non-is_file paths), so scan its path
252
+ # components here exactly as Pass 1 does — a source token in the link's
253
+ # OWN name would otherwise slip the gate. (symlink-to-file names are
254
+ # already covered above via covered_symlinks.)
255
+ path_rules = [
256
+ (rule, frm)
257
+ for rule, frm, _to in rendered_rules
258
+ if rule.paths and _rule_scope_hits(rule, rel_posix, renamed)
259
+ ]
260
+ for i, component in enumerate(rel.parts):
261
+ if _is_root_press(rel, i):
262
+ continue
263
+ for field_name in path_fields:
264
+ value = fields.get(field_name)
265
+ if value is not None and occurs(
266
+ component, field_name, value, substring_fields
267
+ ):
268
+ leaks.append(Leak(rel_posix, field_name, value, "path"))
269
+ for _rule, frm in path_rules:
270
+ if frm in component:
271
+ leaks.append(Leak(rel_posix, "replace_rule", frm, "path"))
272
+ try:
273
+ link = os.readlink(path)
274
+ except OSError:
275
+ leaks.append(Leak(rel_posix, "io", "unreadable", "unverifiable"))
276
+ continue
277
+ for field_name, value in fields.items():
278
+ if occurs(link, field_name, value, substring_fields):
279
+ leaks.append(Leak(rel_posix, field_name, value, "symlink"))
280
+ link_target_posix = symlink_target_posix(rel, link)
281
+ for rule, frm, _to in rendered_rules:
282
+ if (
283
+ rule.paths
284
+ and _rule_scope_hits(rule, link_target_posix, renamed)
285
+ and frm in link
286
+ ):
287
+ leaks.append(Leak(rel_posix, "replace_rule", frm, "symlink"))
288
+ # GITLINK (submodule) index entries reach NEITHER pass above (Fix F2):
289
+ # pass 1 filters on `is_file()` and pass 2 on `is_symlink()`, both False
290
+ # for a submodule mount point (no working-tree materialization needed —
291
+ # a gitlink is a pointer, not a checkout). Scan each gitlink's PATH
292
+ # COMPONENTS only — no content read, no readlink — exactly like the
293
+ # other two path-component loops, honoring the same exclusion posture
294
+ # pass 1 uses (`_is_excluded` + `ROOT_CONTROL`; pass 2's unfiltered gap
295
+ # is a known, separate limitation, not mirrored here).
296
+ for rel_posix in sorted(_gitlink_rels(target)):
297
+ rel = Path(rel_posix)
298
+ if _is_excluded(rel, rules) or rel_posix in ROOT_CONTROL:
299
+ continue
300
+ path_rules = [
301
+ (rule, frm)
302
+ for rule, frm, _to in rendered_rules
303
+ if rule.paths and _rule_scope_hits(rule, rel_posix, renamed)
304
+ ]
305
+ for i, component in enumerate(rel.parts):
306
+ if _is_root_press(rel, i):
307
+ continue
308
+ for field_name in path_fields:
309
+ value = fields.get(field_name)
310
+ if value is not None and occurs(
311
+ component, field_name, value, substring_fields
312
+ ):
313
+ leaks.append(Leak(rel_posix, field_name, value, "path"))
314
+ for _rule, frm in path_rules:
315
+ if frm in component:
316
+ leaks.append(Leak(rel_posix, "replace_rule", frm, "path"))
317
+ return leaks
318
+
319
+
320
+ def render_leak_report(leaks: list[Leak], limit: int = 20) -> str:
321
+ lines = [
322
+ f"error: {len(leaks)} source-identity leftover(s) — rebrand is "
323
+ f"INCOMPLETE; no receipt written."
324
+ ]
325
+ for leak in leaks[:limit]:
326
+ lines.append(f" [{leak.where}] {leak.path}: {leak.field}={leak.value!r}")
327
+ if len(leaks) > limit:
328
+ lines.append(f" … and {len(leaks) - limit} more")
329
+ lines.append(
330
+ "hint: restore the target (git -C <target> checkout . && git clean "
331
+ "-fd), fix the root cause (or, for content that is VALID to keep, "
332
+ "add its directory to BOTH extra_exclude_dirs and verify_ignore in "
333
+ "<target>/press/press-rules.toml — the first skips rewriting, the second skips "
334
+ "this scan), then press again."
335
+ )
336
+ return "\n".join(lines)