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.
- {template_press-3.2.0 → template_press-3.3.0}/PKG-INFO +1 -1
- {template_press-3.2.0 → template_press-3.3.0}/pyproject.toml +4 -2
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/cli.py +98 -8
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/config.py +22 -1
- template_press-3.3.0/src/template_press/rebrand/doctor.py +336 -0
- template_press-3.3.0/src/template_press/rebrand/engine.py +1025 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/identity.py +76 -2
- template_press-3.3.0/src/template_press/rebrand/rules.py +311 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/synthesize.py +49 -2
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/verifier.py +130 -8
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/verify_cli.py +77 -6
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/verify_config.py +7 -4
- template_press-3.2.0/src/template_press/rebrand/doctor.py +0 -151
- template_press-3.2.0/src/template_press/rebrand/engine.py +0 -558
- template_press-3.2.0/src/template_press/rebrand/rules.py +0 -90
- {template_press-3.2.0 → template_press-3.3.0}/README.md +0 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/__init__.py +0 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/press_cli.py +0 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/py.typed +0 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/__init__.py +0 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/discovery.py +0 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/ignores.py +0 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/matcher.py +0 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/receipt.py +0 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/safety.py +0 -0
- {template_press-3.2.0 → template_press-3.3.0}/src/template_press/rebrand/sandbox.py +0 -0
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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)
|