template_press 3.0.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.
@@ -0,0 +1,24 @@
1
+ # Copyright (c) 2025, Steve Morin
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ # this software and associated documentation files (the "Software"), to deal in
5
+ # the Software without restriction, including without limitation the rights to
6
+ # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ # the Software, and to permit persons to whom the Software is furnished to do so,
8
+ # subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in all
11
+ # copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19
+
20
+ """template-press — a standalone external-target rebrand utility."""
21
+
22
+ from importlib import metadata
23
+
24
+ __version__ = metadata.version("template_press")
@@ -0,0 +1,50 @@
1
+ """press — the template-press command line.
2
+
3
+ Noun-verb dispatcher (design 0006): `press rebrand --target …` presses an
4
+ identity onto an external target repo. `provision` and `status` are reserved
5
+ for the M6 Provision phase and currently exit 2 with a pointer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from template_press import __version__
13
+ from template_press.rebrand import cli as rebrand_cli
14
+
15
+ _RESERVED = {"provision", "status"}
16
+
17
+ _USAGE = """\
18
+ usage: press <command> [options]
19
+
20
+ commands:
21
+ rebrand press an identity onto a target repo (press rebrand --help)
22
+ provision configure a target's features (coming in M6)
23
+ status report a target's provisioned state (coming in M6)
24
+ """
25
+
26
+
27
+ def main(argv: list[str] | None = None) -> int:
28
+ args = list(sys.argv[1:] if argv is None else argv)
29
+ if args and args[0] in ("-V", "--version"):
30
+ print(f"press {__version__}")
31
+ return 0
32
+ if not args or args[0] in ("-h", "--help"):
33
+ print(_USAGE)
34
+ return 0
35
+ verb, rest = args[0], args[1:]
36
+ if verb == "rebrand":
37
+ return rebrand_cli.main(rest)
38
+ if verb in _RESERVED:
39
+ print(
40
+ f"error: '{verb}' is part of the Provision phase and is not "
41
+ f"available yet (coming in M6).",
42
+ file=sys.stderr,
43
+ )
44
+ return 2
45
+ print(f"error: unknown command {verb!r}\n\n{_USAGE}", file=sys.stderr)
46
+ return 2
47
+
48
+
49
+ if __name__ == "__main__":
50
+ raise SystemExit(main())
File without changes
File without changes
@@ -0,0 +1,287 @@
1
+ """press rebrand — point the press at a target repo (ARCH-01).
2
+
3
+ Pipeline: preconditions → source identity (config-first, discovery
4
+ validates) → answers → plan → [--dry-run stops here] → apply → regenerate
5
+ lockfiles → VERIFY (no-leak doctor) → receipt. Exit codes: 0 ok, 1 leaks
6
+ found after apply (no receipt), 2 precondition/config error (no writes).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import subprocess # nosec B404 — invokes git/uv on user-supplied targets
13
+ import sys
14
+ import tomllib
15
+ from pathlib import Path
16
+
17
+ from template_press.rebrand.config import (
18
+ SOURCE_CONFIG_REL,
19
+ load_answers,
20
+ load_source_config,
21
+ render_source_config,
22
+ )
23
+ from template_press.rebrand.discovery import discover, mismatches
24
+ from template_press.rebrand.doctor import find_leaks, render_leak_report
25
+ from template_press.rebrand.engine import ApplyReport, apply, build_plan
26
+ from template_press.rebrand.identity import Identity, ValidationError, token_occurs
27
+ from template_press.rebrand.receipt import read_receipt, write_receipt
28
+ from template_press.rebrand.rules import DEFAULT_RULES, Rules, load_rules
29
+
30
+
31
+ def _fail(msg: str) -> int:
32
+ print(f"error: {msg}", file=sys.stderr)
33
+ return 2
34
+
35
+
36
+ def check_preconditions(target: Path, force: bool, allow_dirty: bool) -> str | None:
37
+ """Return an error message, or None when the target is pressable."""
38
+ if not target.is_dir():
39
+ return f"target does not exist or is not a directory: {target}"
40
+ if not (target / ".git").exists():
41
+ return f"target is not a git repository: {target}"
42
+ if read_receipt(target) is not None and not force:
43
+ return (
44
+ "target already has a press receipt (press/press-receipt.toml); "
45
+ "re-press with --force"
46
+ )
47
+ if not allow_dirty:
48
+ status = subprocess.run( # noqa: S603 # nosec B603 B607
49
+ ["git", "-C", str(target), "status", "--porcelain"], # noqa: S607
50
+ check=True,
51
+ capture_output=True,
52
+ text=True,
53
+ )
54
+ if status.stdout.strip():
55
+ return "target working tree is dirty; commit/stash or --allow-dirty"
56
+ return None
57
+
58
+
59
+ def _resolve_source(
60
+ target: Path, override: Path | None, accept_discovery: bool
61
+ ) -> tuple[Identity, bool] | int:
62
+ """Resolve the FROM identity; second element = write source-config later.
63
+
64
+ The write is DEFERRED to main() so it happens only after every exit-2
65
+ gate has passed — keeping "exit 2 means no writes" true by construction.
66
+ """
67
+ write_pending = False
68
+ source = load_source_config(target, override)
69
+ if source is None:
70
+ found = discover(target)
71
+ proposal = {
72
+ "package_name": found.package_name,
73
+ "repo_name": found.repo_name,
74
+ "app_name": found.app_name,
75
+ "author": found.author,
76
+ "email": found.email,
77
+ "owner": found.owner,
78
+ }
79
+ unresolved = [k for k, v in proposal.items() if v is None]
80
+ if unresolved:
81
+ return _fail(
82
+ f"no source-config at {SOURCE_CONFIG_REL} and discovery "
83
+ f"could not resolve: {', '.join(unresolved)}. Write the "
84
+ f"source-config by hand."
85
+ )
86
+ try:
87
+ candidate = Identity.from_mapping(
88
+ {k: v for k, v in proposal.items() if v is not None}
89
+ )
90
+ candidate.validate()
91
+ except ValidationError as exc:
92
+ return _fail(f"discovered identity is invalid: {exc}")
93
+ source = candidate
94
+ write_pending = True
95
+ problems = mismatches(source, discover(target))
96
+ if problems:
97
+ print(
98
+ "error: source-config does not match the target "
99
+ "(refusing to press — this is the silent-half-rebrand guard):",
100
+ )
101
+ for p in problems:
102
+ print(f" {p}")
103
+ return 2
104
+ return source, write_pending
105
+
106
+
107
+ def _collisions(source: Identity, dest: Identity) -> list[str]:
108
+ """Destination values that embed a CHANGED source token.
109
+
110
+ Sequential substitution would re-rewrite such output (old app name
111
+ becoming the new package name chains two replacements), and the doctor
112
+ would flag correct output as a leak (press → press_two). Refusing up
113
+ front with guidance beats either silent corruption or a permanent
114
+ verification failure.
115
+ """
116
+ out: list[str] = []
117
+ src, dst = source.as_dict(), dest.as_dict()
118
+ changed = {f: v for f, v in src.items() if v != dst[f]}
119
+ for dest_field, dest_value in dst.items():
120
+ for src_field, src_value in changed.items():
121
+ if token_occurs(dest_value, src_field, src_value):
122
+ out.append(
123
+ f"destination {dest_field}={dest_value!r} contains the "
124
+ f"source {src_field} token {src_value!r}"
125
+ )
126
+ return out
127
+
128
+
129
+ def main(argv: list[str] | None = None) -> int:
130
+ parser = argparse.ArgumentParser(prog="press rebrand", description=__doc__)
131
+ parser.add_argument("--target", type=Path, required=True)
132
+ parser.add_argument("--config", type=Path, help="answers TOML (TO identity)")
133
+ parser.add_argument("--source-config", type=Path, dest="source_config")
134
+ parser.add_argument("--accept-discovery", action="store_true")
135
+ parser.add_argument("--dry-run", action="store_true")
136
+ parser.add_argument("--force", action="store_true")
137
+ parser.add_argument("--allow-dirty", action="store_true")
138
+ args = parser.parse_args(argv)
139
+
140
+ target = args.target.resolve()
141
+ try:
142
+ problem = check_preconditions(target, args.force, args.allow_dirty)
143
+ if problem is not None:
144
+ return _fail(problem)
145
+
146
+ resolved = _resolve_source(target, args.source_config, args.accept_discovery)
147
+ if isinstance(resolved, int):
148
+ return resolved
149
+ source, write_pending = resolved
150
+ if write_pending and not args.accept_discovery:
151
+ print(
152
+ f"no source-config found at {SOURCE_CONFIG_REL}.\n"
153
+ f"Discovery proposes:\n\n{render_source_config(source)}\n"
154
+ f"Save it there (and commit), or re-run with "
155
+ f"--accept-discovery to write + use it.",
156
+ )
157
+ return 2
158
+
159
+ if args.config is None:
160
+ return _fail("--config ANSWERS.toml is required")
161
+ dest = load_answers(args.config)
162
+
163
+ if source == dest:
164
+ return _fail(
165
+ "source and destination identities are identical — nothing to press"
166
+ )
167
+ collisions = _collisions(source, dest)
168
+ if collisions:
169
+ print(
170
+ "error: destination identity embeds source tokens — a single "
171
+ "press cannot produce a verifiable result; press in two steps "
172
+ "via an intermediate identity:",
173
+ file=sys.stderr,
174
+ )
175
+ for c in collisions:
176
+ print(f" {c}", file=sys.stderr)
177
+ return 2
178
+
179
+ rules = load_rules(target)
180
+ plan = build_plan(target, source, dest, rules)
181
+ print(plan.render())
182
+ if args.dry_run:
183
+ if write_pending:
184
+ print(f"(dry run) would write {SOURCE_CONFIG_REL} from discovery")
185
+ print("(dry run — nothing applied)")
186
+ return 0
187
+ # LAST gate before apply: every exit-2 path (rules/plan included) is
188
+ # behind us, so the deferred source-config write can no longer be
189
+ # followed by a "no writes" exit code.
190
+ if write_pending:
191
+ path = target / SOURCE_CONFIG_REL
192
+ path.parent.mkdir(parents=True, exist_ok=True)
193
+ path.write_text(render_source_config(source), encoding="utf-8")
194
+ print(f"wrote {SOURCE_CONFIG_REL} from discovery")
195
+ except (
196
+ ValidationError,
197
+ tomllib.TOMLDecodeError,
198
+ OSError,
199
+ subprocess.CalledProcessError,
200
+ ) as exc:
201
+ return _fail(str(exc))
202
+ try:
203
+ return _press(target, source, dest, rules)
204
+ except (OSError, subprocess.CalledProcessError) as exc:
205
+ # Exit 2 means "nothing applied"; a mid-apply failure is not that.
206
+ print(
207
+ f"error: {exc} — target may be PARTIALLY rewritten; restore with "
208
+ f"`git -C {target} checkout . && git clean -fd`",
209
+ file=sys.stderr,
210
+ )
211
+ return 1
212
+
213
+
214
+ def _regenerate_lockfiles(target: Path, rules: Rules, report: ApplyReport) -> list[str]:
215
+ """Regenerate listed lockfiles; return the ones that FAILED to regenerate.
216
+
217
+ A lockfile is excluded from both rewriting and the doctor scan, so a
218
+ failed regeneration would leave source-identity content behind invisibly.
219
+ Callers must treat failures as verification failures (no receipt).
220
+ """
221
+ failed: list[str] = []
222
+ for lockfile in rules.regenerate:
223
+ if not (target / lockfile).is_file():
224
+ continue
225
+ if lockfile == "uv.lock":
226
+ result = subprocess.run( # nosec B603 B607
227
+ ["uv", "lock"], # noqa: S607
228
+ cwd=target,
229
+ capture_output=True,
230
+ text=True,
231
+ )
232
+ if result.returncode == 0:
233
+ report.regenerated.append(lockfile)
234
+ else:
235
+ report.skipped.append(f"regenerate {lockfile} (uv lock failed)")
236
+ failed.append(lockfile)
237
+ else:
238
+ report.skipped.append(f"regenerate {lockfile} (no regenerator)")
239
+ failed.append(lockfile)
240
+ return failed
241
+
242
+
243
+ def _press(target: Path, source: Identity, dest: Identity, rules: Rules) -> int:
244
+ report = apply(target, source, dest, rules)
245
+ failed_locks = _regenerate_lockfiles(target, rules, report)
246
+ if failed_locks:
247
+ print(
248
+ f"error: lockfile regeneration failed for "
249
+ f"{', '.join(failed_locks)} — the lockfile still carries the old "
250
+ f"identity and is exempt from the doctor scan, so this rebrand "
251
+ f"is INCOMPLETE; no receipt written. Regenerate it, then re-run "
252
+ f"with --force.",
253
+ file=sys.stderr,
254
+ )
255
+ print(report.render(), file=sys.stderr)
256
+ return 1
257
+ # Verification never honors target-side REWRITE exclusions (EMP-01):
258
+ # neither extra_exclude_files nor extra_exclude_dirs can hide content
259
+ # from the doctor. The only sanctioned exemption is the explicit,
260
+ # committed verify_ignore list — the deliberate ignore set.
261
+ doctor_rules = Rules(
262
+ exclude_dirs=DEFAULT_RULES.exclude_dirs | rules.verify_ignore,
263
+ exclude_files=DEFAULT_RULES.exclude_files,
264
+ regenerate=rules.regenerate,
265
+ verify_ignore=rules.verify_ignore,
266
+ )
267
+ leaks = find_leaks(target, source, doctor_rules, dest=dest)
268
+ if leaks:
269
+ print(render_leak_report(leaks), file=sys.stderr)
270
+ print(report.render(), file=sys.stderr)
271
+ return 1
272
+ receipt_path = write_receipt(target, source, dest, report)
273
+ source_config_path = target / SOURCE_CONFIG_REL
274
+ source_config_path.parent.mkdir(parents=True, exist_ok=True)
275
+ source_config_path.write_text(render_source_config(dest), encoding="utf-8")
276
+ print(report.render())
277
+ if report.skipped:
278
+ print("skipped (review):")
279
+ for entry in report.skipped:
280
+ print(f" {entry}")
281
+ print(f"verified: no identity leftovers. receipt: {receipt_path}")
282
+ print(f"updated {SOURCE_CONFIG_REL} to the new identity")
283
+ return 0
284
+
285
+
286
+ if __name__ == "__main__":
287
+ raise SystemExit(main())
@@ -0,0 +1,67 @@
1
+ """Load/render the two per-run identity configs (OQ3 two-file model).
2
+
3
+ source-config (FROM, committed in the target at press/press-source.toml) — the
4
+ authoritative identity being replaced. answers (TO) — the identity being
5
+ pressed in, from an [answers] TOML at a caller-supplied path
6
+ (conventionally named press-answers.toml, but any path works).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import tomllib
12
+ from pathlib import Path
13
+
14
+ from template_press.rebrand.identity import Identity, ValidationError
15
+
16
+ SOURCE_CONFIG_REL = Path("press") / "press-source.toml"
17
+
18
+
19
+ def toml_string(value: str) -> str:
20
+ """Render a str as a TOML basic-string literal (quoted, escaped)."""
21
+ out = []
22
+ for ch in value:
23
+ if ch in ('"', "\\"):
24
+ out.append("\\" + ch)
25
+ elif ch == "\n":
26
+ out.append("\\n")
27
+ elif ch == "\t":
28
+ out.append("\\t")
29
+ elif ord(ch) < 0x20 or ch == "\x7f":
30
+ out.append(f"\\u{ord(ch):04X}")
31
+ else:
32
+ out.append(ch)
33
+ return '"' + "".join(out) + '"'
34
+
35
+
36
+ def load_identity_toml(path: Path, table: str) -> Identity:
37
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
38
+ section = data.get(table)
39
+ if not isinstance(section, dict):
40
+ raise ValidationError(f"{path}: missing [{table}] table")
41
+ identity = Identity.from_mapping(
42
+ {k: v for k, v in section.items() if isinstance(v, str)}
43
+ )
44
+ identity.validate()
45
+ return identity
46
+
47
+
48
+ def load_source_config(target: Path, override: Path | None) -> Identity | None:
49
+ path = override if override is not None else target / SOURCE_CONFIG_REL
50
+ if not path.is_file():
51
+ return None
52
+ return load_identity_toml(path, "identity")
53
+
54
+
55
+ def render_source_config(identity: Identity) -> str:
56
+ lines = [
57
+ "# press/press-source.toml — this repo's CURRENT identity (the FROM side",
58
+ "# of a rebrand). Authoritative: press validates it against the repo",
59
+ "# and refuses to run on mismatch. Commit this file.",
60
+ "[identity]",
61
+ ]
62
+ lines += [f"{k} = {toml_string(v)}" for k, v in identity.as_dict_prompted().items()]
63
+ return "\n".join(lines) + "\n"
64
+
65
+
66
+ def load_answers(path: Path) -> Identity:
67
+ return load_identity_toml(path, "answers")
@@ -0,0 +1,109 @@
1
+ """Discover a target repo's identity — the VALIDATOR, never the authority.
2
+
3
+ Per OQ3 (decision log 2026-06-15): the committed source-config is the
4
+ authoritative FROM identity; discovery cross-checks it against the target
5
+ (pyproject [project].name / authors, the [project.scripts] key, git origin,
6
+ src-vs-flat layout) and the CLI fails loudly on any mismatch. This replaces
7
+ the silent half-rebrand failure mode (EMPIRICAL R2) with a hard stop.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import subprocess # nosec B404 — reads git origin of the target
14
+ import tomllib
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from template_press.rebrand.identity import Identity
19
+
20
+ _ORIGIN_RE = re.compile(
21
+ r"^(?:https?://github\.com/|git@github\.com:)"
22
+ r"(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Discovered:
28
+ package_name: str | None
29
+ app_name: str | None
30
+ owner: str | None
31
+ repo_name: str | None
32
+ author: str | None
33
+ email: str | None
34
+ layout: str | None # "src" | "flat" | None
35
+
36
+
37
+ def _origin(target: Path) -> tuple[str | None, str | None]:
38
+ result = subprocess.run( # noqa: S603 # nosec B603 B607
39
+ ["git", "-C", str(target), "remote", "get-url", "origin"], # noqa: S607
40
+ capture_output=True,
41
+ text=True,
42
+ )
43
+ if result.returncode != 0:
44
+ return None, None
45
+ m = _ORIGIN_RE.match(result.stdout.strip())
46
+ return (m["owner"], m["repo"]) if m else (None, None)
47
+
48
+
49
+ def discover(target: Path) -> Discovered:
50
+ package_name = app_name = author = email = None
51
+ pyproject_path = target / "pyproject.toml"
52
+ if pyproject_path.is_file():
53
+ data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
54
+ project = data.get("project", {})
55
+ raw_name = project.get("name")
56
+ if isinstance(raw_name, str):
57
+ package_name = raw_name.replace("-", "_")
58
+ scripts = project.get("scripts", {})
59
+ if isinstance(scripts, dict) and scripts:
60
+ app_name = str(next(iter(scripts)))
61
+ authors = project.get("authors", [])
62
+ if authors and isinstance(authors[0], dict):
63
+ author = authors[0].get("name")
64
+ email = authors[0].get("email")
65
+ owner, repo_name = _origin(target)
66
+ layout: str | None = None
67
+ if package_name is not None:
68
+ if (target / "src" / package_name).is_dir():
69
+ layout = "src"
70
+ elif (target / package_name).is_dir():
71
+ layout = "flat"
72
+ return Discovered(
73
+ package_name=package_name,
74
+ app_name=app_name,
75
+ owner=owner,
76
+ repo_name=repo_name,
77
+ author=author,
78
+ email=email,
79
+ layout=layout,
80
+ )
81
+
82
+
83
+ def mismatches(source: Identity, found: Discovered) -> list[str]:
84
+ """Non-empty means the source-config does NOT describe this target."""
85
+ out: list[str] = []
86
+ checks: tuple[tuple[str, str | None], ...] = (
87
+ ("package_name", found.package_name),
88
+ ("app_name", found.app_name),
89
+ ("owner", found.owner),
90
+ ("repo_name", found.repo_name),
91
+ ("author", found.author),
92
+ ("email", found.email),
93
+ )
94
+ declared = source.as_dict()
95
+ for field_name, discovered_value in checks:
96
+ if discovered_value is None:
97
+ continue # undiscoverable field — config stands unchallenged
98
+ if discovered_value != declared[field_name]:
99
+ out.append(
100
+ f"{field_name}: source-config says "
101
+ f"{declared[field_name]!r} but target shows "
102
+ f"{discovered_value!r}"
103
+ )
104
+ if found.package_name is not None and found.layout is None:
105
+ out.append(
106
+ f"layout: pyproject declares {found.package_name!r} but neither "
107
+ f"src/{found.package_name}/ nor {found.package_name}/ exists"
108
+ )
109
+ return out
@@ -0,0 +1,97 @@
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
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from template_press.rebrand.engine import iter_target_files
15
+ from template_press.rebrand.identity import Identity, token_occurs
16
+ from template_press.rebrand.rules import Rules
17
+
18
+ PATH_FIELDS: tuple[str, ...] = ("package_name", "repo_name", "app_name")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Leak:
23
+ path: str
24
+ field: str
25
+ value: str
26
+ where: str # "content" | "path" | "unverifiable"
27
+
28
+
29
+ def _read_for_scan(path: Path) -> str | None:
30
+ """Content for scanning; None for binary/symlink; OSError propagates.
31
+
32
+ Unlike the engine's lenient reader, the doctor must NOT silently skip an
33
+ unreadable file — a file it cannot scan is a file it cannot certify.
34
+ """
35
+ if path.is_symlink():
36
+ return None # content lives outside the target; the name is scanned
37
+ try:
38
+ return path.read_text(encoding="utf-8")
39
+ except UnicodeDecodeError:
40
+ return None # binary: the rewrite pass cannot alter it either
41
+
42
+
43
+ def find_leaks(
44
+ target: Path,
45
+ source: Identity,
46
+ rules: Rules,
47
+ dest: Identity | None = None,
48
+ ) -> list[Leak]:
49
+ """Scan for surviving source-identity tokens.
50
+
51
+ When ``dest`` is given, only fields that actually CHANGED are scanned:
52
+ an unchanged field (same author across a rename) is not a leak — its
53
+ token legitimately remains everywhere. Without ``dest`` all fields are
54
+ scanned (full-rebrand semantics).
55
+ """
56
+ leaks: list[Leak] = []
57
+ fields = source.as_dict()
58
+ if dest is not None:
59
+ dest_fields = dest.as_dict()
60
+ fields = {k: v for k, v in fields.items() if v != dest_fields[k]}
61
+ for path in iter_target_files(target, rules):
62
+ rel = path.relative_to(target)
63
+ rel_posix = rel.as_posix()
64
+ try:
65
+ text = _read_for_scan(path)
66
+ except OSError:
67
+ leaks.append(Leak(rel_posix, "io", "unreadable", "unverifiable"))
68
+ text = None
69
+ if text is not None:
70
+ for field_name, value in fields.items():
71
+ if token_occurs(text, field_name, value):
72
+ leaks.append(Leak(rel_posix, field_name, value, "content"))
73
+ for component in rel.parts:
74
+ for field_name in PATH_FIELDS:
75
+ value = fields.get(field_name)
76
+ if value is not None and token_occurs(component, field_name, value):
77
+ leaks.append(Leak(rel_posix, field_name, value, "path"))
78
+ return leaks
79
+
80
+
81
+ def render_leak_report(leaks: list[Leak], limit: int = 20) -> str:
82
+ lines = [
83
+ f"error: {len(leaks)} source-identity leftover(s) — rebrand is "
84
+ f"INCOMPLETE; no receipt written."
85
+ ]
86
+ for leak in leaks[:limit]:
87
+ lines.append(f" [{leak.where}] {leak.path}: {leak.field}={leak.value!r}")
88
+ if len(leaks) > limit:
89
+ lines.append(f" … and {len(leaks) - limit} more")
90
+ lines.append(
91
+ "hint: restore the target (git -C <target> checkout . && git clean "
92
+ "-fd), fix the root cause (or, for content that is VALID to keep, "
93
+ "add its directory to BOTH extra_exclude_dirs and verify_ignore in "
94
+ "<target>/press/press-rules.toml — the first skips rewriting, the second skips "
95
+ "this scan), then press again."
96
+ )
97
+ return "\n".join(lines)