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.
Files changed (73) hide show
  1. {ptsip-0.3.1/src/PTSIP.egg-info → ptsip-0.3.2}/PKG-INFO +1 -1
  2. {ptsip-0.3.1 → ptsip-0.3.2}/pyproject.toml +1 -1
  3. {ptsip-0.3.1 → ptsip-0.3.2/src/PTSIP.egg-info}/PKG-INFO +1 -1
  4. {ptsip-0.3.1 → ptsip-0.3.2}/src/PTSIP.egg-info/SOURCES.txt +1 -0
  5. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/resolution/profile_projection.py +26 -4
  6. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/cli.py +33 -1
  7. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/constants.py +1 -1
  8. ptsip-0.3.2/src/ptsip/topology.py +747 -0
  9. {ptsip-0.3.1 → ptsip-0.3.2}/LICENSE +0 -0
  10. {ptsip-0.3.1 → ptsip-0.3.2}/MANIFEST.in +0 -0
  11. {ptsip-0.3.1 → ptsip-0.3.2}/README.md +0 -0
  12. {ptsip-0.3.1 → ptsip-0.3.2}/setup.cfg +0 -0
  13. {ptsip-0.3.1 → ptsip-0.3.2}/src/PTSIP.egg-info/dependency_links.txt +0 -0
  14. {ptsip-0.3.1 → ptsip-0.3.2}/src/PTSIP.egg-info/entry_points.txt +0 -0
  15. {ptsip-0.3.1 → ptsip-0.3.2}/src/PTSIP.egg-info/requires.txt +0 -0
  16. {ptsip-0.3.1 → ptsip-0.3.2}/src/PTSIP.egg-info/top_level.txt +0 -0
  17. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/__init__.py +0 -0
  18. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/__main__.py +0 -0
  19. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/__init__.py +0 -0
  20. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/client.py +0 -0
  21. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/github_client.py +0 -0
  22. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/server.py +0 -0
  23. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/service.py +0 -0
  24. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/app/store.py +0 -0
  25. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/artifact_evidence.py +0 -0
  26. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/build_resolution.py +0 -0
  27. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/__init__.py +0 -0
  28. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/generator.py +0 -0
  29. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/generator_core.py +0 -0
  30. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/i18n.py +0 -0
  31. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/model.py +0 -0
  32. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/render.py +0 -0
  33. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/resolution/__init__.py +0 -0
  34. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/resolution/model.py +0 -0
  35. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/resolution/parser.py +0 -0
  36. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/resolution/resolver.py +0 -0
  37. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/transports/__init__.py +0 -0
  38. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/clarification/transports/github_issue.py +0 -0
  39. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/conformance.py +0 -0
  40. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/conformance_audit.py +0 -0
  41. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/conformance_engine.py +0 -0
  42. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/doctor.py +0 -0
  43. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/__init__.py +0 -0
  44. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/components.py +0 -0
  45. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/dependencies.py +0 -0
  46. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/dependencies_030.py +0 -0
  47. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/dotnet.py +0 -0
  48. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/go.py +0 -0
  49. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/inventory.py +0 -0
  50. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/javascript.py +0 -0
  51. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/lexing.py +0 -0
  52. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/inspection/source_adapters.py +0 -0
  53. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/lifecycle_evidence.py +0 -0
  54. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/model.py +0 -0
  55. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/pilot/__init__.py +0 -0
  56. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/pilot/runner.py +0 -0
  57. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/repository/__init__.py +0 -0
  58. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/repository/discover.py +0 -0
  59. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/repository/remote.py +0 -0
  60. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/repository/snapshot.py +0 -0
  61. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/review_evidence.py +0 -0
  62. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/spec_identity.py +0 -0
  63. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/specdata/ptsip-agent-classification.schema.json +0 -0
  64. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/specdata/ptsip-artifact-evidence.schema.json +0 -0
  65. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/specdata/ptsip-diagnostic.schema.json +0 -0
  66. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/specdata/ptsip-profile.schema.json +0 -0
  67. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/specdata/ptsip-registry.yaml +0 -0
  68. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/storage/__init__.py +0 -0
  69. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/storage/local_state.py +0 -0
  70. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/validation/__init__.py +0 -0
  71. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/validation/components.py +0 -0
  72. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/validation/profile.py +0 -0
  73. {ptsip-0.3.1 → ptsip-0.3.2}/src/ptsip/validation/rules.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PTSIP
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Reference development tooling for the Product-Toolchain SDK Isolation Policy
5
5
  Author: kwaksinwoo01
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PTSIP"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Reference development tooling for the Product-Toolchain SDK Isolation Policy"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PTSIP
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Reference development tooling for the Product-Toolchain SDK Isolation Policy
5
5
  Author: kwaksinwoo01
6
6
  License-Expression: Apache-2.0
@@ -22,6 +22,7 @@ src/ptsip/lifecycle_evidence.py
22
22
  src/ptsip/model.py
23
23
  src/ptsip/review_evidence.py
24
24
  src/ptsip/spec_identity.py
25
+ src/ptsip/topology.py
25
26
  src/ptsip/app/__init__.py
26
27
  src/ptsip/app/client.py
27
28
  src/ptsip/app/github_client.py
@@ -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 / "ptsip.yaml"
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("w", suffix=".yaml", dir=root, delete=False, encoding="utf-8", newline="\n") as handle:
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("ptsip.yaml changed after decision projection validation; refusing to overwrite concurrent changes")
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(prepare_local_profile(repository_root, component_id, include, answer))
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(repo.root, component_id, [str(item) for item in include], answer)
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
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  TOOL_NAME = "PTSIP Tool"
4
- TOOL_VERSION = "0.3.1"
4
+ TOOL_VERSION = "0.3.2"
5
5
  SPEC_NAME = "Product–Toolchain SDK Isolation Policy"
6
6
  SPEC_ACRONYM = "PTSIP"
7
7
  SPEC_VERSION = "0.2.0-draft"
@@ -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