PTSIP 0.3.1__tar.gz → 0.3.2__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.
- {ptsip-0.3.1/src/PTSIP.egg-info → ptsip-0.3.2}/PKG-INFO +1 -1
- {ptsip-0.3.1 → ptsip-0.3.2}/pyproject.toml +1 -1
- {ptsip-0.3.1 → ptsip-0.3.2/src/PTSIP.egg-info}/PKG-INFO +1 -1
- {ptsip-0.3.1 → ptsip-0.3.2}/src/PTSIP.egg-info/SOURCES.txt +1 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/resolution/profile_projection.py +26 -4
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/cli.py +33 -1
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/constants.py +1 -1
- ptsip-0.3.2/src/ptsip/topology.py +747 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/LICENSE +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/MANIFEST.in +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/README.md +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/setup.cfg +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/PTSIP.egg-info/dependency_links.txt +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/PTSIP.egg-info/entry_points.txt +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/PTSIP.egg-info/requires.txt +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/PTSIP.egg-info/top_level.txt +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/__init__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/__main__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/__init__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/client.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/github_client.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/server.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/service.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/store.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/artifact_evidence.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/build_resolution.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/__init__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/generator.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/generator_core.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/i18n.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/model.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/render.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/resolution/__init__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/resolution/model.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/resolution/parser.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/resolution/resolver.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/transports/__init__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/transports/github_issue.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/conformance.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/conformance_audit.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/conformance_engine.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/doctor.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/__init__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/components.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/dependencies.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/dependencies_030.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/dotnet.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/go.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/inventory.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/javascript.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/lexing.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/source_adapters.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/lifecycle_evidence.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/model.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/pilot/__init__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/pilot/runner.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/repository/__init__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/repository/discover.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/repository/remote.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/repository/snapshot.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/review_evidence.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/spec_identity.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/specdata/ptsip-agent-classification.schema.json +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/specdata/ptsip-artifact-evidence.schema.json +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/specdata/ptsip-diagnostic.schema.json +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/specdata/ptsip-profile.schema.json +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/specdata/ptsip-registry.yaml +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/storage/__init__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/storage/local_state.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/validation/__init__.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/validation/components.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/validation/profile.py +0 -0
- {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/validation/rules.py +0 -0
|
@@ -141,20 +141,37 @@ def load_profile_text(text: str | None) -> dict[str, object] | None:
|
|
|
141
141
|
return payload
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def _local_profile_path(repository_root: Path, explicit: str | Path | None) -> Path:
|
|
145
|
+
if explicit is None:
|
|
146
|
+
return repository_root / "ptsip.yaml"
|
|
147
|
+
profile = Path(explicit).expanduser().resolve()
|
|
148
|
+
if not profile.parent.is_dir():
|
|
149
|
+
raise FileNotFoundError(f"PTSIP profile parent directory does not exist: {profile.parent}")
|
|
150
|
+
return profile
|
|
151
|
+
|
|
152
|
+
|
|
144
153
|
def prepare_local_profile(
|
|
145
154
|
repository_root: str | Path,
|
|
146
155
|
component_id: str,
|
|
147
156
|
include: tuple[str, ...] | list[str],
|
|
148
157
|
answer: DecisionAnswer,
|
|
158
|
+
profile_path: str | Path | None = None,
|
|
149
159
|
) -> PreparedLocalProfile:
|
|
150
160
|
root = Path(repository_root).resolve()
|
|
151
|
-
profile = root
|
|
161
|
+
profile = _local_profile_path(root, profile_path)
|
|
152
162
|
expected_source = profile.read_text(encoding="utf-8-sig") if profile.is_file() else None
|
|
153
163
|
existing = load_profile_text(expected_source)
|
|
154
164
|
projected = project_payload(existing, component_id, include, answer)
|
|
155
165
|
content = dump_payload(projected)
|
|
156
166
|
|
|
157
|
-
with tempfile.NamedTemporaryFile(
|
|
167
|
+
with tempfile.NamedTemporaryFile(
|
|
168
|
+
"w",
|
|
169
|
+
suffix=".yaml",
|
|
170
|
+
dir=profile.parent,
|
|
171
|
+
delete=False,
|
|
172
|
+
encoding="utf-8",
|
|
173
|
+
newline="\n",
|
|
174
|
+
) as handle:
|
|
158
175
|
temp = Path(handle.name)
|
|
159
176
|
handle.write(content)
|
|
160
177
|
try:
|
|
@@ -170,7 +187,9 @@ def prepare_local_profile(
|
|
|
170
187
|
def write_prepared_local_profile(prepared: PreparedLocalProfile) -> Path:
|
|
171
188
|
current_source = prepared.path.read_text(encoding="utf-8-sig") if prepared.path.is_file() else None
|
|
172
189
|
if current_source != prepared.expected_source:
|
|
173
|
-
raise RuntimeError(
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
f"{prepared.path} changed after decision projection validation; refusing to overwrite concurrent changes"
|
|
192
|
+
)
|
|
174
193
|
with tempfile.NamedTemporaryFile(
|
|
175
194
|
"w",
|
|
176
195
|
suffix=".yaml",
|
|
@@ -194,5 +213,8 @@ def apply_local_profile(
|
|
|
194
213
|
component_id: str,
|
|
195
214
|
include: tuple[str, ...] | list[str],
|
|
196
215
|
answer: DecisionAnswer,
|
|
216
|
+
profile_path: str | Path | None = None,
|
|
197
217
|
) -> Path:
|
|
198
|
-
return write_prepared_local_profile(
|
|
218
|
+
return write_prepared_local_profile(
|
|
219
|
+
prepare_local_profile(repository_root, component_id, include, answer, profile_path)
|
|
220
|
+
)
|
|
@@ -25,6 +25,7 @@ from .pilot.runner import run_pilot
|
|
|
25
25
|
from .repository.discover import discover_repository
|
|
26
26
|
from .repository.snapshot import capture_snapshot, compare_snapshots
|
|
27
27
|
from .spec_identity import current_spec_identity
|
|
28
|
+
from .topology import migrate_topology
|
|
28
29
|
from .validation.profile import validate_profile
|
|
29
30
|
|
|
30
31
|
|
|
@@ -125,6 +126,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
125
126
|
help="Explicitly resolve a pending PTSIP decision from an active user/coding-agent session and apply the profile locally",
|
|
126
127
|
)
|
|
127
128
|
p_resolve.add_argument("path", nargs="?", default=".")
|
|
129
|
+
p_resolve.add_argument("--profile", help="Explicit project-profile path; defaults to repository-root ptsip.yaml")
|
|
128
130
|
p_resolve.add_argument("--decision", required=True, help="Decision/clarification ID returned by ptsip gate")
|
|
129
131
|
p_resolve.add_argument("--classification", required=True, choices=("PRODUCT", "TOOLCHAIN", "NEUTRAL_CONTRACT"))
|
|
130
132
|
p_resolve.add_argument("--purpose", required=True)
|
|
@@ -139,6 +141,18 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
139
141
|
p_resolve.add_argument("--actor", default="coding-agent-session", help="Audit actor label; no free-form inference is performed")
|
|
140
142
|
p_resolve.add_argument("--control-plane", help="PTSIP control-plane base URL; otherwise PTSIP_CONTROL_PLANE_URL")
|
|
141
143
|
p_resolve.add_argument("--json", action="store_true")
|
|
144
|
+
|
|
145
|
+
p_topology = sub.add_parser(
|
|
146
|
+
"topology",
|
|
147
|
+
help="Plan or explicitly apply a repository component-root migration without changing architecture classification",
|
|
148
|
+
)
|
|
149
|
+
p_topology.add_argument("path", nargs="?", default=".")
|
|
150
|
+
p_topology.add_argument("--profile", help="Explicit project-profile path")
|
|
151
|
+
p_topology.add_argument("--component", help="Component ID; required for component-based profiles")
|
|
152
|
+
p_topology.add_argument("--from", dest="from_root", required=True, help="Existing repository-relative component root")
|
|
153
|
+
p_topology.add_argument("--to", dest="to_root", required=True, help="Target repository-relative component root")
|
|
154
|
+
p_topology.add_argument("--apply", action="store_true", help="Apply the reviewed migration plan; default is dry-run")
|
|
155
|
+
p_topology.add_argument("--json", action="store_true")
|
|
142
156
|
return parser
|
|
143
157
|
|
|
144
158
|
|
|
@@ -205,6 +219,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
205
219
|
if result.outcome == "NON_CONFORMANT":
|
|
206
220
|
return 5
|
|
207
221
|
return 6
|
|
222
|
+
if args.command == "topology":
|
|
223
|
+
repo = discover_repository(args.path)
|
|
224
|
+
result = migrate_topology(
|
|
225
|
+
repo.root,
|
|
226
|
+
args.profile,
|
|
227
|
+
args.from_root,
|
|
228
|
+
args.to_root,
|
|
229
|
+
args.component,
|
|
230
|
+
apply=args.apply,
|
|
231
|
+
)
|
|
232
|
+
_emit(result, args.json)
|
|
233
|
+
return 0
|
|
208
234
|
if args.command == "clarify":
|
|
209
235
|
if args.repo and args.publish != "github-issue":
|
|
210
236
|
raise ValueError("--repo requires --publish github-issue")
|
|
@@ -351,7 +377,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
351
377
|
# profile-conflicting answer therefore cannot become the first
|
|
352
378
|
# authoritative resolution merely because validation happened late.
|
|
353
379
|
try:
|
|
354
|
-
prepared = prepare_local_profile(
|
|
380
|
+
prepared = prepare_local_profile(
|
|
381
|
+
repo.root,
|
|
382
|
+
component_id,
|
|
383
|
+
[str(item) for item in include],
|
|
384
|
+
answer,
|
|
385
|
+
args.profile,
|
|
386
|
+
)
|
|
355
387
|
except (ValueError, RuntimeError) as exc:
|
|
356
388
|
_emit({"status": "CONFLICT", "message": str(exc), "decision": decision}, args.json)
|
|
357
389
|
return 8
|
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .clarification.resolution import validate_projected_payload
|
|
13
|
+
from .inspection.dependencies_030 import scan_dependency_edges
|
|
14
|
+
from .model import EdgeType
|
|
15
|
+
from .repository.discover import discover_repository
|
|
16
|
+
from .repository.snapshot import repository_files
|
|
17
|
+
from .validation.profile import find_profile, validate_profile
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_PATH_FIELDS = ("include", "exclude", "manifests", "analysis_inputs")
|
|
21
|
+
_SOURCE_SUFFIXES = {
|
|
22
|
+
".py",
|
|
23
|
+
".pyi",
|
|
24
|
+
".js",
|
|
25
|
+
".jsx",
|
|
26
|
+
".mjs",
|
|
27
|
+
".cjs",
|
|
28
|
+
".ts",
|
|
29
|
+
".tsx",
|
|
30
|
+
".mts",
|
|
31
|
+
".cts",
|
|
32
|
+
".go",
|
|
33
|
+
".cs",
|
|
34
|
+
".java",
|
|
35
|
+
".kt",
|
|
36
|
+
".kts",
|
|
37
|
+
".rs",
|
|
38
|
+
".rb",
|
|
39
|
+
".php",
|
|
40
|
+
".swift",
|
|
41
|
+
".scala",
|
|
42
|
+
".c",
|
|
43
|
+
".cc",
|
|
44
|
+
".cpp",
|
|
45
|
+
".h",
|
|
46
|
+
".hpp",
|
|
47
|
+
}
|
|
48
|
+
_BUILD_NAMES = {
|
|
49
|
+
"pyproject.toml",
|
|
50
|
+
"setup.py",
|
|
51
|
+
"setup.cfg",
|
|
52
|
+
"requirements.txt",
|
|
53
|
+
"package.json",
|
|
54
|
+
"package-lock.json",
|
|
55
|
+
"pnpm-lock.yaml",
|
|
56
|
+
"yarn.lock",
|
|
57
|
+
"tsconfig.json",
|
|
58
|
+
"go.mod",
|
|
59
|
+
"go.sum",
|
|
60
|
+
"cargo.toml",
|
|
61
|
+
"cargo.lock",
|
|
62
|
+
"makefile",
|
|
63
|
+
"cmakelists.txt",
|
|
64
|
+
"dockerfile",
|
|
65
|
+
"build.gradle",
|
|
66
|
+
"build.gradle.kts",
|
|
67
|
+
"pom.xml",
|
|
68
|
+
}
|
|
69
|
+
_DOC_SUFFIXES = {".md", ".mdx", ".rst", ".adoc", ".txt"}
|
|
70
|
+
_CLASSIFICATION_BY_BOUNDARY = {
|
|
71
|
+
"product": "PRODUCT",
|
|
72
|
+
"toolchain": "TOOLCHAIN",
|
|
73
|
+
"neutral_contract": "NEUTRAL_CONTRACT",
|
|
74
|
+
}
|
|
75
|
+
_DEPENDENCY_REFERENCE_TYPES = {
|
|
76
|
+
EdgeType.IMPORTS,
|
|
77
|
+
EdgeType.LINKS,
|
|
78
|
+
EdgeType.LOADS,
|
|
79
|
+
EdgeType.INVOKES,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class ReferenceImpact:
|
|
85
|
+
category: str
|
|
86
|
+
path: str
|
|
87
|
+
line: int | None
|
|
88
|
+
text: str
|
|
89
|
+
|
|
90
|
+
def as_dict(self) -> dict[str, object]:
|
|
91
|
+
return asdict(self)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class ProfileChange:
|
|
96
|
+
location: str
|
|
97
|
+
before: str
|
|
98
|
+
after: str
|
|
99
|
+
|
|
100
|
+
def as_dict(self) -> dict[str, str]:
|
|
101
|
+
return asdict(self)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class TopologyMigrationPlan:
|
|
106
|
+
repository_root: str
|
|
107
|
+
profile_path: str
|
|
108
|
+
from_root: str
|
|
109
|
+
to_root: str
|
|
110
|
+
component_id: str | None
|
|
111
|
+
classification: str
|
|
112
|
+
ownership_mode: str
|
|
113
|
+
classification_before: dict[str, str]
|
|
114
|
+
classification_after: dict[str, str]
|
|
115
|
+
source_files: tuple[str, ...]
|
|
116
|
+
profile_changes: tuple[ProfileChange, ...]
|
|
117
|
+
reference_impacts: tuple[ReferenceImpact, ...]
|
|
118
|
+
dependency_scan_issues: tuple[tuple[str, str, str], ...]
|
|
119
|
+
dependency_edge_count: int
|
|
120
|
+
projected_profile: str
|
|
121
|
+
|
|
122
|
+
def as_dict(self) -> dict[str, object]:
|
|
123
|
+
impacts: dict[str, list[dict[str, object]]] = {
|
|
124
|
+
"PROFILE": [],
|
|
125
|
+
"IMPORT": [],
|
|
126
|
+
"BUILD": [],
|
|
127
|
+
"CI": [],
|
|
128
|
+
"DOCUMENTATION": [],
|
|
129
|
+
"OTHER": [],
|
|
130
|
+
}
|
|
131
|
+
for item in self.reference_impacts:
|
|
132
|
+
impacts.setdefault(item.category, []).append(item.as_dict())
|
|
133
|
+
manual_count = sum(
|
|
134
|
+
1 for item in self.reference_impacts if item.category != "PROFILE"
|
|
135
|
+
)
|
|
136
|
+
issues = [
|
|
137
|
+
{"adapter": adapter, "path": path, "message": message}
|
|
138
|
+
for adapter, path, message in self.dependency_scan_issues
|
|
139
|
+
]
|
|
140
|
+
return {
|
|
141
|
+
"repository_root": self.repository_root,
|
|
142
|
+
"profile_path": self.profile_path,
|
|
143
|
+
"migration": {
|
|
144
|
+
"from": self.from_root,
|
|
145
|
+
"to": self.to_root,
|
|
146
|
+
"component_id": self.component_id,
|
|
147
|
+
"classification": self.classification,
|
|
148
|
+
"ownership_mode": self.ownership_mode,
|
|
149
|
+
},
|
|
150
|
+
"classification": {
|
|
151
|
+
"before": self.classification_before,
|
|
152
|
+
"after": self.classification_after,
|
|
153
|
+
"preserved": self.classification_before == self.classification_after,
|
|
154
|
+
},
|
|
155
|
+
"source_files": list(self.source_files),
|
|
156
|
+
"profile_changes": [item.as_dict() for item in self.profile_changes],
|
|
157
|
+
"reference_impacts": impacts,
|
|
158
|
+
"reference_analysis": {
|
|
159
|
+
"dependency_edge_count": self.dependency_edge_count,
|
|
160
|
+
"dependency_scan_issue_count": len(issues),
|
|
161
|
+
"dependency_scan_issues": issues,
|
|
162
|
+
"dependency_scan_complete": not issues,
|
|
163
|
+
},
|
|
164
|
+
"manual_reference_updates_required": manual_count,
|
|
165
|
+
"reference_review_required": bool(manual_count or issues),
|
|
166
|
+
"automatic_actions": [
|
|
167
|
+
"move repository root",
|
|
168
|
+
"rewrite project-profile path declarations",
|
|
169
|
+
"verify architecture classification is unchanged",
|
|
170
|
+
"validate the projected profile after the move",
|
|
171
|
+
],
|
|
172
|
+
"automatic_reference_rewrite": False,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _normalize_root(value: str) -> str:
|
|
177
|
+
text = value.replace("\\", "/").strip()
|
|
178
|
+
while text.startswith("./"):
|
|
179
|
+
text = text[2:]
|
|
180
|
+
text = text.strip("/")
|
|
181
|
+
if not text or text == ".":
|
|
182
|
+
raise ValueError("Topology root must be a non-empty repository-relative path")
|
|
183
|
+
if any(char in text for char in "*?[]"):
|
|
184
|
+
raise ValueError("Topology roots must not contain glob syntax")
|
|
185
|
+
path = PurePosixPath(text)
|
|
186
|
+
if path.is_absolute() or ".." in path.parts:
|
|
187
|
+
raise ValueError("Topology roots must stay within the repository")
|
|
188
|
+
return path.as_posix()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _normalize_selector(value: str) -> str:
|
|
192
|
+
text = value.replace("\\", "/").strip()
|
|
193
|
+
while text.startswith("./"):
|
|
194
|
+
text = text[2:]
|
|
195
|
+
return text.strip("/")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _inside(root: str, candidate: str) -> bool:
|
|
199
|
+
return candidate == root or candidate.startswith(root + "/")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _within_repository(root: Path, candidate: Path) -> bool:
|
|
203
|
+
try:
|
|
204
|
+
candidate.relative_to(root)
|
|
205
|
+
except ValueError:
|
|
206
|
+
return False
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _check_topology_path(root: Path, relative: str, *, must_exist: bool) -> Path:
|
|
211
|
+
candidate = root / relative
|
|
212
|
+
if must_exist:
|
|
213
|
+
if not candidate.exists():
|
|
214
|
+
raise FileNotFoundError(f"Topology source root does not exist: {candidate}")
|
|
215
|
+
if candidate.is_symlink():
|
|
216
|
+
raise ValueError("Topology source root must not be a symbolic link")
|
|
217
|
+
if not candidate.is_dir():
|
|
218
|
+
raise ValueError("Topology source root must be a directory")
|
|
219
|
+
if not _within_repository(root, candidate.resolve()):
|
|
220
|
+
raise ValueError("Topology source root resolves outside the repository")
|
|
221
|
+
return candidate
|
|
222
|
+
|
|
223
|
+
if candidate.exists():
|
|
224
|
+
raise FileExistsError(f"Topology target root already exists: {candidate}")
|
|
225
|
+
existing_parent = candidate.parent
|
|
226
|
+
while not existing_parent.exists() and existing_parent != root:
|
|
227
|
+
existing_parent = existing_parent.parent
|
|
228
|
+
if not existing_parent.exists() or not _within_repository(root, existing_parent.resolve()):
|
|
229
|
+
raise ValueError("Topology target root would resolve outside the repository")
|
|
230
|
+
return candidate
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _rewrite_reference(value: str, old: str, new: str) -> str:
|
|
234
|
+
normalized = value.replace("\\", "/")
|
|
235
|
+
prefix = ""
|
|
236
|
+
while normalized.startswith("./"):
|
|
237
|
+
prefix += "./"
|
|
238
|
+
normalized = normalized[2:]
|
|
239
|
+
if normalized == old:
|
|
240
|
+
return prefix + new
|
|
241
|
+
if normalized.startswith(old + "/"):
|
|
242
|
+
return prefix + new + normalized[len(old) :]
|
|
243
|
+
return value
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _classification_snapshot(payload: dict[str, object]) -> tuple[str, dict[str, str]]:
|
|
247
|
+
components = payload.get("components")
|
|
248
|
+
if isinstance(components, list):
|
|
249
|
+
result: dict[str, str] = {}
|
|
250
|
+
for item in components:
|
|
251
|
+
if isinstance(item, dict):
|
|
252
|
+
component_id = str(item.get("id", ""))
|
|
253
|
+
classification = str(item.get("classification", ""))
|
|
254
|
+
if component_id:
|
|
255
|
+
result[component_id] = classification
|
|
256
|
+
return "components", result
|
|
257
|
+
boundaries = payload.get("boundaries")
|
|
258
|
+
if isinstance(boundaries, dict):
|
|
259
|
+
result = {}
|
|
260
|
+
for plane, classification in _CLASSIFICATION_BY_BOUNDARY.items():
|
|
261
|
+
item = boundaries.get(plane)
|
|
262
|
+
if isinstance(item, dict):
|
|
263
|
+
result[plane] = classification
|
|
264
|
+
return "boundaries", result
|
|
265
|
+
raise ValueError("Profile declares neither components nor boundaries")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _load_profile(profile: Path) -> dict[str, object]:
|
|
269
|
+
payload = yaml.safe_load(profile.read_text(encoding="utf-8-sig"))
|
|
270
|
+
if not isinstance(payload, dict):
|
|
271
|
+
raise ValueError("PTSIP profile root must be a mapping")
|
|
272
|
+
return payload
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _rewrite_profile(
|
|
276
|
+
payload: dict[str, object],
|
|
277
|
+
old: str,
|
|
278
|
+
new: str,
|
|
279
|
+
component_id: str | None,
|
|
280
|
+
) -> tuple[dict[str, object], str | None, str, list[ProfileChange]]:
|
|
281
|
+
projected = yaml.safe_load(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True))
|
|
282
|
+
assert isinstance(projected, dict)
|
|
283
|
+
mode, _before = _classification_snapshot(payload)
|
|
284
|
+
changes: list[ProfileChange] = []
|
|
285
|
+
|
|
286
|
+
if mode == "components":
|
|
287
|
+
components = projected.get("components")
|
|
288
|
+
assert isinstance(components, list)
|
|
289
|
+
if not component_id:
|
|
290
|
+
raise ValueError("--component is required when the profile uses component ownership")
|
|
291
|
+
selected: dict[str, object] | None = None
|
|
292
|
+
selected_index = -1
|
|
293
|
+
for index, item in enumerate(components):
|
|
294
|
+
if isinstance(item, dict) and str(item.get("id", "")) == component_id:
|
|
295
|
+
selected = item
|
|
296
|
+
selected_index = index
|
|
297
|
+
break
|
|
298
|
+
if selected is None:
|
|
299
|
+
raise ValueError(f"Component {component_id!r} is not declared in the selected profile")
|
|
300
|
+
includes = selected.get("include", [])
|
|
301
|
+
if not isinstance(includes, list) or not any(
|
|
302
|
+
_inside(old, _normalize_selector(str(item))) for item in includes
|
|
303
|
+
):
|
|
304
|
+
raise ValueError(
|
|
305
|
+
f"Component {component_id!r} has no include selector rooted at {old!r}"
|
|
306
|
+
)
|
|
307
|
+
classification = str(selected.get("classification", ""))
|
|
308
|
+
|
|
309
|
+
# Rewrite every component path declaration beneath the moved root. This
|
|
310
|
+
# preserves nested ownership instead of collapsing it into the selected
|
|
311
|
+
# parent component.
|
|
312
|
+
for index, item in enumerate(components):
|
|
313
|
+
if not isinstance(item, dict):
|
|
314
|
+
continue
|
|
315
|
+
for field in _PATH_FIELDS:
|
|
316
|
+
values = item.get(field)
|
|
317
|
+
if not isinstance(values, list):
|
|
318
|
+
continue
|
|
319
|
+
for value_index, raw in enumerate(values):
|
|
320
|
+
before = str(raw)
|
|
321
|
+
after = _rewrite_reference(before, old, new)
|
|
322
|
+
if after == before:
|
|
323
|
+
continue
|
|
324
|
+
values[value_index] = after
|
|
325
|
+
changes.append(
|
|
326
|
+
ProfileChange(
|
|
327
|
+
location=f"components[{index}].{field}[{value_index}]",
|
|
328
|
+
before=before,
|
|
329
|
+
after=after,
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
if not any(
|
|
333
|
+
change.location.startswith(f"components[{selected_index}].include[")
|
|
334
|
+
for change in changes
|
|
335
|
+
):
|
|
336
|
+
raise ValueError(
|
|
337
|
+
f"Component {component_id!r} does not expose a rewritable root selector for {old!r}"
|
|
338
|
+
)
|
|
339
|
+
return projected, component_id, classification, changes
|
|
340
|
+
|
|
341
|
+
boundaries = projected.get("boundaries")
|
|
342
|
+
assert isinstance(boundaries, dict)
|
|
343
|
+
owners: list[tuple[str, int]] = []
|
|
344
|
+
for plane, item in boundaries.items():
|
|
345
|
+
if not isinstance(item, dict):
|
|
346
|
+
continue
|
|
347
|
+
roots = item.get("roots")
|
|
348
|
+
if not isinstance(roots, list):
|
|
349
|
+
continue
|
|
350
|
+
for index, raw in enumerate(roots):
|
|
351
|
+
if _normalize_selector(str(raw)) == old:
|
|
352
|
+
owners.append((str(plane), index))
|
|
353
|
+
if len(owners) != 1:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"Boundary ownership requires exactly one root equal to {old!r}; found {len(owners)}"
|
|
356
|
+
)
|
|
357
|
+
plane, _owner_index = owners[0]
|
|
358
|
+
classification = _CLASSIFICATION_BY_BOUNDARY.get(plane)
|
|
359
|
+
if classification is None:
|
|
360
|
+
raise ValueError(f"Unsupported boundary plane {plane!r}")
|
|
361
|
+
for current_plane, item in boundaries.items():
|
|
362
|
+
if not isinstance(item, dict):
|
|
363
|
+
continue
|
|
364
|
+
roots = item.get("roots")
|
|
365
|
+
if not isinstance(roots, list):
|
|
366
|
+
continue
|
|
367
|
+
for index, raw in enumerate(roots):
|
|
368
|
+
before = str(raw)
|
|
369
|
+
after = _rewrite_reference(before, old, new)
|
|
370
|
+
if after == before:
|
|
371
|
+
continue
|
|
372
|
+
roots[index] = after
|
|
373
|
+
changes.append(
|
|
374
|
+
ProfileChange(
|
|
375
|
+
location=f"boundaries.{current_plane}.roots[{index}]",
|
|
376
|
+
before=before,
|
|
377
|
+
after=after,
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
return projected, None, classification, changes
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _reference_category(path: str, line: str) -> str:
|
|
384
|
+
normalized = path.replace("\\", "/")
|
|
385
|
+
lower_path = normalized.casefold()
|
|
386
|
+
name = PurePosixPath(normalized).name.casefold()
|
|
387
|
+
suffix = PurePosixPath(normalized).suffix.casefold()
|
|
388
|
+
if lower_path.startswith(".github/workflows/"):
|
|
389
|
+
return "CI"
|
|
390
|
+
if name in _BUILD_NAMES or suffix in {
|
|
391
|
+
".csproj",
|
|
392
|
+
".fsproj",
|
|
393
|
+
".vbproj",
|
|
394
|
+
".sln",
|
|
395
|
+
".props",
|
|
396
|
+
".targets",
|
|
397
|
+
}:
|
|
398
|
+
return "BUILD"
|
|
399
|
+
if lower_path.startswith("docs/") or suffix in _DOC_SUFFIXES:
|
|
400
|
+
return "DOCUMENTATION"
|
|
401
|
+
if suffix in _SOURCE_SUFFIXES:
|
|
402
|
+
lowered_line = line.casefold()
|
|
403
|
+
if any(
|
|
404
|
+
token in lowered_line
|
|
405
|
+
for token in ("import ", "from ", "require(", "include", "load", "source")
|
|
406
|
+
):
|
|
407
|
+
return "IMPORT"
|
|
408
|
+
return "OTHER"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _text_reference_impacts(root: Path, profile: Path, old: str) -> list[ReferenceImpact]:
|
|
412
|
+
_mode, paths, errors = repository_files(root)
|
|
413
|
+
if errors:
|
|
414
|
+
raise RuntimeError("Repository reference scan is incomplete: " + "; ".join(errors))
|
|
415
|
+
results: list[ReferenceImpact] = []
|
|
416
|
+
profile_resolved = profile.resolve()
|
|
417
|
+
for rel in paths:
|
|
418
|
+
path = root / rel
|
|
419
|
+
try:
|
|
420
|
+
if path.is_symlink():
|
|
421
|
+
continue
|
|
422
|
+
category_override = "PROFILE" if path.resolve() == profile_resolved else None
|
|
423
|
+
data = path.read_bytes()
|
|
424
|
+
except OSError:
|
|
425
|
+
continue
|
|
426
|
+
if b"\0" in data:
|
|
427
|
+
continue
|
|
428
|
+
try:
|
|
429
|
+
text = data.decode("utf-8-sig")
|
|
430
|
+
except UnicodeDecodeError:
|
|
431
|
+
continue
|
|
432
|
+
for line_number, line in enumerate(text.splitlines(), 1):
|
|
433
|
+
if old not in line and old.replace("/", "\\") not in line:
|
|
434
|
+
continue
|
|
435
|
+
results.append(
|
|
436
|
+
ReferenceImpact(
|
|
437
|
+
category=category_override or _reference_category(rel, line),
|
|
438
|
+
path=rel,
|
|
439
|
+
line=line_number,
|
|
440
|
+
text=line.strip()[:240],
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
return results
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _dependency_reference_impacts(
|
|
447
|
+
root: Path,
|
|
448
|
+
old: str,
|
|
449
|
+
) -> tuple[list[ReferenceImpact], tuple[tuple[str, str, str], ...], int]:
|
|
450
|
+
scan = scan_dependency_edges(root)
|
|
451
|
+
impacts: list[ReferenceImpact] = []
|
|
452
|
+
for edge in scan.edges:
|
|
453
|
+
source = edge.source.replace("\\", "/")
|
|
454
|
+
resolved = edge.resolved_path.replace("\\", "/") if edge.resolved_path else None
|
|
455
|
+
if not _inside(old, source) and not (resolved and _inside(old, resolved)):
|
|
456
|
+
continue
|
|
457
|
+
source_category = _reference_category(source, "")
|
|
458
|
+
if source_category in {"CI", "BUILD"}:
|
|
459
|
+
category = source_category
|
|
460
|
+
elif edge.edge_type in _DEPENDENCY_REFERENCE_TYPES:
|
|
461
|
+
category = "IMPORT"
|
|
462
|
+
else:
|
|
463
|
+
category = "OTHER"
|
|
464
|
+
target_note = f" -> {resolved}" if resolved else ""
|
|
465
|
+
impacts.append(
|
|
466
|
+
ReferenceImpact(
|
|
467
|
+
category=category,
|
|
468
|
+
path=edge.source,
|
|
469
|
+
line=edge.line,
|
|
470
|
+
text=f"{edge.edge_type.value} {edge.target}{target_note}",
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
issues = tuple((item.adapter, item.path, item.message) for item in scan.issues)
|
|
474
|
+
return impacts, issues, len(scan.edges)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _merge_impacts(*groups: list[ReferenceImpact]) -> list[ReferenceImpact]:
|
|
478
|
+
unique: dict[tuple[str, str, int | None, str], ReferenceImpact] = {}
|
|
479
|
+
for group in groups:
|
|
480
|
+
for item in group:
|
|
481
|
+
key = (item.category, item.path, item.line, item.text)
|
|
482
|
+
unique[key] = item
|
|
483
|
+
return sorted(
|
|
484
|
+
unique.values(),
|
|
485
|
+
key=lambda item: (item.category, item.path, item.line or 0, item.text),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def plan_topology_migration(
|
|
490
|
+
repository_root: str | Path,
|
|
491
|
+
profile_path: str | Path | None,
|
|
492
|
+
from_root: str,
|
|
493
|
+
to_root: str,
|
|
494
|
+
component_id: str | None = None,
|
|
495
|
+
) -> TopologyMigrationPlan:
|
|
496
|
+
root = Path(repository_root).resolve()
|
|
497
|
+
old = _normalize_root(from_root)
|
|
498
|
+
new = _normalize_root(to_root)
|
|
499
|
+
if old == new:
|
|
500
|
+
raise ValueError("Topology source and target roots are identical")
|
|
501
|
+
if _inside(old, new) or _inside(new, old):
|
|
502
|
+
raise ValueError("Topology source and target roots must not contain one another")
|
|
503
|
+
|
|
504
|
+
source = _check_topology_path(root, old, must_exist=True)
|
|
505
|
+
_check_topology_path(root, new, must_exist=False)
|
|
506
|
+
|
|
507
|
+
selected_profile = find_profile(root, profile_path)
|
|
508
|
+
if selected_profile is None:
|
|
509
|
+
raise FileNotFoundError("No PTSIP project profile found for topology migration")
|
|
510
|
+
if selected_profile.is_symlink():
|
|
511
|
+
raise ValueError("Topology migration does not rewrite a symbolic-link profile")
|
|
512
|
+
profile = selected_profile.resolve()
|
|
513
|
+
if not _within_repository(root, profile):
|
|
514
|
+
raise ValueError("Topology migration requires the selected profile to be inside the repository")
|
|
515
|
+
if _within_repository(source.resolve(), profile):
|
|
516
|
+
raise ValueError("The selected PTSIP profile cannot be located inside the root being moved")
|
|
517
|
+
|
|
518
|
+
validation = validate_profile(root, profile)
|
|
519
|
+
if not validation.valid:
|
|
520
|
+
raise ValueError("Current PTSIP profile is invalid: " + "; ".join(validation.errors))
|
|
521
|
+
|
|
522
|
+
payload = _load_profile(profile)
|
|
523
|
+
ownership_mode, before = _classification_snapshot(payload)
|
|
524
|
+
projected, selected_component, classification, changes = _rewrite_profile(
|
|
525
|
+
payload, old, new, component_id
|
|
526
|
+
)
|
|
527
|
+
after_mode, after = _classification_snapshot(projected)
|
|
528
|
+
if after_mode != ownership_mode or before != after:
|
|
529
|
+
raise RuntimeError("Projected topology migration changes architecture classification")
|
|
530
|
+
projected_errors = validate_projected_payload(projected)
|
|
531
|
+
if projected_errors:
|
|
532
|
+
raise ValueError("Projected PTSIP profile is invalid: " + "; ".join(projected_errors))
|
|
533
|
+
if not changes:
|
|
534
|
+
raise ValueError("Topology migration would not change any profile path declaration")
|
|
535
|
+
|
|
536
|
+
_mode, repository_paths, scan_errors = repository_files(root)
|
|
537
|
+
if scan_errors:
|
|
538
|
+
raise RuntimeError("Repository file scan is incomplete: " + "; ".join(scan_errors))
|
|
539
|
+
source_files = tuple(
|
|
540
|
+
path for path in repository_paths if _inside(old, path.replace("\\", "/"))
|
|
541
|
+
)
|
|
542
|
+
if not source_files:
|
|
543
|
+
raise ValueError("Topology source root contains no repository files to migrate")
|
|
544
|
+
|
|
545
|
+
dependency_impacts, dependency_issues, dependency_edge_count = _dependency_reference_impacts(
|
|
546
|
+
root, old
|
|
547
|
+
)
|
|
548
|
+
text_impacts = _text_reference_impacts(root, profile, old)
|
|
549
|
+
references = _merge_impacts(dependency_impacts, text_impacts)
|
|
550
|
+
projected_text = yaml.safe_dump(projected, sort_keys=False, allow_unicode=True)
|
|
551
|
+
return TopologyMigrationPlan(
|
|
552
|
+
repository_root=str(root),
|
|
553
|
+
profile_path=str(profile),
|
|
554
|
+
from_root=old,
|
|
555
|
+
to_root=new,
|
|
556
|
+
component_id=selected_component,
|
|
557
|
+
classification=classification,
|
|
558
|
+
ownership_mode=ownership_mode,
|
|
559
|
+
classification_before=before,
|
|
560
|
+
classification_after=after,
|
|
561
|
+
source_files=source_files,
|
|
562
|
+
profile_changes=tuple(changes),
|
|
563
|
+
reference_impacts=tuple(references),
|
|
564
|
+
dependency_scan_issues=dependency_issues,
|
|
565
|
+
dependency_edge_count=dependency_edge_count,
|
|
566
|
+
projected_profile=projected_text,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _git(root: Path, *args: str) -> None:
|
|
571
|
+
proc = subprocess.run(
|
|
572
|
+
["git", "-C", str(root), *args],
|
|
573
|
+
stdout=subprocess.PIPE,
|
|
574
|
+
stderr=subprocess.PIPE,
|
|
575
|
+
text=True,
|
|
576
|
+
check=False,
|
|
577
|
+
)
|
|
578
|
+
if proc.returncode != 0:
|
|
579
|
+
message = proc.stderr.strip() or proc.stdout.strip() or "git command failed"
|
|
580
|
+
raise RuntimeError(message)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
584
|
+
with tempfile.NamedTemporaryFile(
|
|
585
|
+
"w",
|
|
586
|
+
suffix=path.suffix or ".yaml",
|
|
587
|
+
dir=path.parent,
|
|
588
|
+
delete=False,
|
|
589
|
+
encoding="utf-8",
|
|
590
|
+
newline="\n",
|
|
591
|
+
) as handle:
|
|
592
|
+
temp = Path(handle.name)
|
|
593
|
+
handle.write(content)
|
|
594
|
+
try:
|
|
595
|
+
temp.replace(path)
|
|
596
|
+
finally:
|
|
597
|
+
if temp.exists():
|
|
598
|
+
temp.unlink()
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _rollback_git(
|
|
602
|
+
root: Path,
|
|
603
|
+
profile: Path,
|
|
604
|
+
from_root: str,
|
|
605
|
+
to_root: str,
|
|
606
|
+
moved: bool,
|
|
607
|
+
) -> list[str]:
|
|
608
|
+
errors: list[str] = []
|
|
609
|
+
try:
|
|
610
|
+
profile_rel = profile.relative_to(root).as_posix()
|
|
611
|
+
_git(root, "reset", "--quiet", "HEAD", "--", profile_rel)
|
|
612
|
+
except Exception as exc:
|
|
613
|
+
errors.append(f"profile index reset failed: {exc}")
|
|
614
|
+
if moved:
|
|
615
|
+
try:
|
|
616
|
+
_git(root, "mv", "--", to_root, from_root)
|
|
617
|
+
except Exception as exc:
|
|
618
|
+
errors.append(f"root rollback failed: {exc}")
|
|
619
|
+
return errors
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def apply_topology_migration(plan: TopologyMigrationPlan) -> dict[str, object]:
|
|
623
|
+
root = Path(plan.repository_root)
|
|
624
|
+
profile = Path(plan.profile_path)
|
|
625
|
+
source = root / plan.from_root
|
|
626
|
+
target = root / plan.to_root
|
|
627
|
+
repo = discover_repository(root)
|
|
628
|
+
if repo.is_git and repo.dirty:
|
|
629
|
+
raise RuntimeError("Topology --apply requires a clean Git working tree and index")
|
|
630
|
+
original_profile = profile.read_text(encoding="utf-8-sig")
|
|
631
|
+
created_parent = not target.parent.exists()
|
|
632
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
633
|
+
moved = False
|
|
634
|
+
try:
|
|
635
|
+
if repo.is_git:
|
|
636
|
+
_git(root, "mv", "--", plan.from_root, plan.to_root)
|
|
637
|
+
move_method = "git-mv"
|
|
638
|
+
else:
|
|
639
|
+
shutil.move(str(source), str(target))
|
|
640
|
+
move_method = "filesystem-move"
|
|
641
|
+
moved = True
|
|
642
|
+
|
|
643
|
+
# Full validation is intentionally deferred until the move has happened:
|
|
644
|
+
# component selectors now point at the target path and repository_files()
|
|
645
|
+
# can observe that target (including the Git index after git mv).
|
|
646
|
+
with tempfile.NamedTemporaryFile(
|
|
647
|
+
"w",
|
|
648
|
+
suffix=".yaml",
|
|
649
|
+
dir=profile.parent,
|
|
650
|
+
delete=False,
|
|
651
|
+
encoding="utf-8",
|
|
652
|
+
newline="\n",
|
|
653
|
+
) as handle:
|
|
654
|
+
projected_temp = Path(handle.name)
|
|
655
|
+
handle.write(plan.projected_profile)
|
|
656
|
+
try:
|
|
657
|
+
validation = validate_profile(root, projected_temp)
|
|
658
|
+
finally:
|
|
659
|
+
if projected_temp.exists():
|
|
660
|
+
projected_temp.unlink()
|
|
661
|
+
if not validation.valid:
|
|
662
|
+
raise ValueError(
|
|
663
|
+
"Post-move projected PTSIP profile is invalid: " + "; ".join(validation.errors)
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
_atomic_write(profile, plan.projected_profile)
|
|
667
|
+
final_payload = _load_profile(profile)
|
|
668
|
+
final_mode, final_classification = _classification_snapshot(final_payload)
|
|
669
|
+
if final_mode != plan.ownership_mode or final_classification != plan.classification_before:
|
|
670
|
+
raise RuntimeError("Applied topology migration changed architecture classification")
|
|
671
|
+
final_validation = validate_profile(root, profile)
|
|
672
|
+
if not final_validation.valid:
|
|
673
|
+
raise ValueError(
|
|
674
|
+
"Applied PTSIP profile is invalid: " + "; ".join(final_validation.errors)
|
|
675
|
+
)
|
|
676
|
+
if repo.is_git:
|
|
677
|
+
profile_rel = profile.relative_to(root).as_posix()
|
|
678
|
+
_git(root, "add", "--", profile_rel)
|
|
679
|
+
|
|
680
|
+
payload = plan.as_dict()
|
|
681
|
+
payload.update(
|
|
682
|
+
{
|
|
683
|
+
"status": (
|
|
684
|
+
"APPLIED_WITH_REFERENCE_REVIEW"
|
|
685
|
+
if payload["reference_review_required"]
|
|
686
|
+
else "APPLIED"
|
|
687
|
+
),
|
|
688
|
+
"applied": True,
|
|
689
|
+
"move_method": move_method,
|
|
690
|
+
"git_index_updated": bool(repo.is_git),
|
|
691
|
+
"profile_validation": final_validation.as_dict(),
|
|
692
|
+
}
|
|
693
|
+
)
|
|
694
|
+
return payload
|
|
695
|
+
except Exception as exc:
|
|
696
|
+
rollback_errors: list[str] = []
|
|
697
|
+
try:
|
|
698
|
+
if profile.exists() and profile.read_text(encoding="utf-8-sig") != original_profile:
|
|
699
|
+
_atomic_write(profile, original_profile)
|
|
700
|
+
except Exception as rollback_exc:
|
|
701
|
+
rollback_errors.append(f"profile content restore failed: {rollback_exc}")
|
|
702
|
+
|
|
703
|
+
if repo.is_git:
|
|
704
|
+
rollback_errors.extend(
|
|
705
|
+
_rollback_git(root, profile, plan.from_root, plan.to_root, moved)
|
|
706
|
+
)
|
|
707
|
+
elif moved:
|
|
708
|
+
try:
|
|
709
|
+
if target.exists():
|
|
710
|
+
shutil.move(str(target), str(source))
|
|
711
|
+
except Exception as rollback_exc:
|
|
712
|
+
rollback_errors.append(f"root rollback failed: {rollback_exc}")
|
|
713
|
+
|
|
714
|
+
if created_parent:
|
|
715
|
+
try:
|
|
716
|
+
os.removedirs(target.parent)
|
|
717
|
+
except OSError:
|
|
718
|
+
pass
|
|
719
|
+
if rollback_errors:
|
|
720
|
+
raise RuntimeError(
|
|
721
|
+
"Topology migration failed and rollback was incomplete: "
|
|
722
|
+
+ "; ".join(rollback_errors)
|
|
723
|
+
) from exc
|
|
724
|
+
raise
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def migrate_topology(
|
|
728
|
+
repository_root: str | Path,
|
|
729
|
+
profile_path: str | Path | None,
|
|
730
|
+
from_root: str,
|
|
731
|
+
to_root: str,
|
|
732
|
+
component_id: str | None = None,
|
|
733
|
+
*,
|
|
734
|
+
apply: bool = False,
|
|
735
|
+
) -> dict[str, object]:
|
|
736
|
+
plan = plan_topology_migration(
|
|
737
|
+
repository_root,
|
|
738
|
+
profile_path,
|
|
739
|
+
from_root,
|
|
740
|
+
to_root,
|
|
741
|
+
component_id,
|
|
742
|
+
)
|
|
743
|
+
if not apply:
|
|
744
|
+
payload = plan.as_dict()
|
|
745
|
+
payload.update({"status": "PLAN", "applied": False})
|
|
746
|
+
return payload
|
|
747
|
+
return apply_topology_migration(plan)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|