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.
- template_press/__init__.py +24 -0
- template_press/press_cli.py +50 -0
- template_press/py.typed +0 -0
- template_press/rebrand/__init__.py +0 -0
- template_press/rebrand/cli.py +287 -0
- template_press/rebrand/config.py +67 -0
- template_press/rebrand/discovery.py +109 -0
- template_press/rebrand/doctor.py +97 -0
- template_press/rebrand/engine.py +258 -0
- template_press/rebrand/identity.py +184 -0
- template_press/rebrand/receipt.py +58 -0
- template_press/rebrand/rules.py +87 -0
- template_press-3.0.0.dist-info/METADATA +129 -0
- template_press-3.0.0.dist-info/RECORD +16 -0
- template_press-3.0.0.dist-info/WHEEL +4 -0
- template_press-3.0.0.dist-info/entry_points.txt +4 -0
|
@@ -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())
|
template_press/py.typed
ADDED
|
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)
|