@xenonbyte/req-2-plan 0.4.2 → 0.4.4
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.
package/package.json
CHANGED
|
@@ -6,6 +6,11 @@ import os
|
|
|
6
6
|
from dataclasses import asdict, dataclass, field
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
try:
|
|
10
|
+
import tomllib # Python 3.11+
|
|
11
|
+
except ImportError: # pragma: no cover - older interpreters: pyproject parsing degrades to a no-op
|
|
12
|
+
tomllib = None
|
|
13
|
+
|
|
9
14
|
from tools.workflow_cli.repo_baseline import SKIP_DIRS, scan_repo_baseline
|
|
10
15
|
|
|
11
16
|
_CONFIG_NAMES = {
|
|
@@ -39,6 +44,41 @@ def _append_npm_dependencies(pack: ProjectContextPack, dependencies: object, *,
|
|
|
39
44
|
pack.dependencies.append(dep)
|
|
40
45
|
|
|
41
46
|
|
|
47
|
+
def _append_pyproject_facts(pack: ProjectContextPack, repo_path: Path) -> None:
|
|
48
|
+
"""Collect PEP 621 [project] dependencies and a pytest signal from pyproject.toml.
|
|
49
|
+
|
|
50
|
+
Poetry/PDM private tables are out of scope; without tomllib (Python < 3.11)
|
|
51
|
+
this is a no-op, matching the pack's best-effort scan semantics."""
|
|
52
|
+
pyproject = repo_path / "pyproject.toml"
|
|
53
|
+
if tomllib is None or not pyproject.exists():
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
57
|
+
except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError):
|
|
58
|
+
return
|
|
59
|
+
project = data.get("project")
|
|
60
|
+
project = project if isinstance(project, dict) else {}
|
|
61
|
+
raw_deps = project.get("dependencies")
|
|
62
|
+
specs = [s for s in raw_deps if isinstance(s, str)] if isinstance(raw_deps, list) else []
|
|
63
|
+
optional = project.get("optional-dependencies")
|
|
64
|
+
optional_specs: list[str] = []
|
|
65
|
+
if isinstance(optional, dict):
|
|
66
|
+
for group in optional.values():
|
|
67
|
+
if isinstance(group, list):
|
|
68
|
+
optional_specs.extend(s for s in group if isinstance(s, str))
|
|
69
|
+
if (specs or optional_specs) and "pip" not in pack.package_managers:
|
|
70
|
+
pack.package_managers.append("pip")
|
|
71
|
+
for spec in specs:
|
|
72
|
+
pack.dependencies.append({"name": spec, "version": "", "ecosystem": "pip"})
|
|
73
|
+
for spec in optional_specs:
|
|
74
|
+
pack.dependencies.append({"name": spec, "version": "", "ecosystem": "pip", "dev": True})
|
|
75
|
+
tool = data.get("tool")
|
|
76
|
+
has_pytest_config = isinstance(tool, dict) and isinstance(tool.get("pytest"), dict)
|
|
77
|
+
mentions_pytest = any(s.lower().startswith("pytest") for s in specs + optional_specs)
|
|
78
|
+
if (has_pytest_config or mentions_pytest) and "python -m pytest" not in pack.test_commands:
|
|
79
|
+
pack.test_commands.append("python -m pytest")
|
|
80
|
+
|
|
81
|
+
|
|
42
82
|
def build_context_pack(repo_path: Path) -> ProjectContextPack:
|
|
43
83
|
repo_path = Path(repo_path).resolve()
|
|
44
84
|
baseline = scan_repo_baseline(repo_path)
|
|
@@ -68,6 +108,8 @@ def build_context_pack(repo_path: Path) -> ProjectContextPack:
|
|
|
68
108
|
if not pack.test_commands:
|
|
69
109
|
pack.test_commands.append("python -m pytest")
|
|
70
110
|
|
|
111
|
+
_append_pyproject_facts(pack, repo_path)
|
|
112
|
+
|
|
71
113
|
for root, dirs, files in os.walk(repo_path):
|
|
72
114
|
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
|
|
73
115
|
rel_root = Path(root).relative_to(repo_path)
|
|
@@ -431,13 +431,14 @@ def _check_plan_context_pack(run_dir: Path) -> list[str]:
|
|
|
431
431
|
|
|
432
432
|
def _check_plan_file_refs(run_dir: Path, content: str) -> list[str]:
|
|
433
433
|
"""Hard-check Files paths against the Context Pack repo_root. create-type tasks
|
|
434
|
-
|
|
434
|
+
must target paths that do not exist yet (R19); the part after '::' (a symbol) is
|
|
435
|
+
advisory and not checked (no AST pack yet)."""
|
|
435
436
|
repo_root = _context_pack_repo_root(run_dir)
|
|
436
437
|
if repo_root is None:
|
|
437
438
|
return [] # no usable ground truth; standard tier blocks via _check_plan_context_pack
|
|
438
439
|
issues: list[str] = []
|
|
439
440
|
for body in _iter_plan_task_bodies(content):
|
|
440
|
-
|
|
441
|
+
is_create = _normalized_change_type(_task_change_type(body)) == "create"
|
|
441
442
|
files_field = _plan_task_field_body(body, "Files")
|
|
442
443
|
for path_part in _plan_task_file_paths(files_field):
|
|
443
444
|
path = Path(path_part)
|
|
@@ -453,11 +454,18 @@ def _check_plan_file_refs(run_dir: Path, content: str) -> list[str]:
|
|
|
453
454
|
)
|
|
454
455
|
continue
|
|
455
456
|
if not resolved.exists():
|
|
456
|
-
if not
|
|
457
|
+
if not is_create:
|
|
457
458
|
issues.append(
|
|
458
459
|
f"PLAN-TASK Files references missing path {path_part!r} "
|
|
459
460
|
"(mark the task 'Change Type: create' if it is a new file)."
|
|
460
461
|
)
|
|
462
|
+
elif is_create:
|
|
463
|
+
# R19: create must not silently mean "overwrite an existing file".
|
|
464
|
+
issues.append(
|
|
465
|
+
f"PLAN-TASK Files references path {path_part!r} that already exists "
|
|
466
|
+
"under 'Change Type: create'; use 'modify' for existing files or "
|
|
467
|
+
"split the task."
|
|
468
|
+
)
|
|
461
469
|
return issues
|
|
462
470
|
|
|
463
471
|
|
|
@@ -10,6 +10,8 @@ import json
|
|
|
10
10
|
import hashlib
|
|
11
11
|
import shutil
|
|
12
12
|
import shlex
|
|
13
|
+
import stat
|
|
14
|
+
from dataclasses import dataclass
|
|
13
15
|
from datetime import datetime, timezone
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
from typing import Any
|
|
@@ -40,6 +42,21 @@ KNOWN_OBSOLETE_PLATFORM_TARGETS = {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
|
|
45
|
+
@dataclass
|
|
46
|
+
class _FileSnapshot:
|
|
47
|
+
path: Path
|
|
48
|
+
content: bytes
|
|
49
|
+
mode: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _InstallSnapshot:
|
|
54
|
+
manifest_path: Path
|
|
55
|
+
manifest_text: str
|
|
56
|
+
files: list[_FileSnapshot]
|
|
57
|
+
backup_files: list[_FileSnapshot]
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
def _now_ts() -> str:
|
|
44
61
|
"""Return UTC timestamp for backup filenames."""
|
|
45
62
|
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
|
|
@@ -84,17 +101,24 @@ class InstallService:
|
|
|
84
101
|
)
|
|
85
102
|
|
|
86
103
|
manifest_path = self._manifest_path(platform)
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
backup_dir = self.manifest_root / "install" / "backups" / platform
|
|
105
|
+
self._validate_install_plan(platform, manifest_path, backup_dir)
|
|
106
|
+
prior_install = (
|
|
107
|
+
self._capture_install_snapshot(platform, manifest_path)
|
|
108
|
+
if manifest_path.exists()
|
|
109
|
+
else None
|
|
110
|
+
)
|
|
89
111
|
|
|
90
112
|
installed_paths: list[str] = []
|
|
91
113
|
backups: list[dict[str, str]] = []
|
|
92
114
|
written: list[Path] = []
|
|
93
|
-
|
|
115
|
+
manifest_written = False
|
|
94
116
|
|
|
95
117
|
try:
|
|
118
|
+
if prior_install is not None:
|
|
119
|
+
self.uninstall(platform)
|
|
120
|
+
|
|
96
121
|
bin_dir = self.manifest_root / "bin"
|
|
97
|
-
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
98
122
|
|
|
99
123
|
# Copy bin scripts
|
|
100
124
|
for src in sorted(self.repo_root.glob("tools/r2p-*")):
|
|
@@ -104,7 +128,7 @@ class InstallService:
|
|
|
104
128
|
src.read_text(encoding="utf-8"),
|
|
105
129
|
self.repo_root,
|
|
106
130
|
)
|
|
107
|
-
|
|
131
|
+
self._write_managed_file(
|
|
108
132
|
dest, content, backups, installed_paths, written, backup_dir
|
|
109
133
|
)
|
|
110
134
|
shutil.copymode(str(src), str(dest))
|
|
@@ -120,7 +144,7 @@ class InstallService:
|
|
|
120
144
|
skill_src = template_dir / "SKILL.md"
|
|
121
145
|
skill_dest = platform_home / "skills" / "r2p" / "SKILL.md"
|
|
122
146
|
content = _render(skill_src.read_text(), R2P_VERSION, str(bin_dir))
|
|
123
|
-
|
|
147
|
+
self._write_managed_file(
|
|
124
148
|
skill_dest, content, backups, installed_paths, written, backup_dir
|
|
125
149
|
)
|
|
126
150
|
|
|
@@ -129,7 +153,7 @@ class InstallService:
|
|
|
129
153
|
for src in sorted(cmd_dir.glob("r2p-*.md")):
|
|
130
154
|
dest = platform_home / "commands" / src.name
|
|
131
155
|
content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
|
|
132
|
-
|
|
156
|
+
self._write_managed_file(
|
|
133
157
|
dest, content, backups, installed_paths, written, backup_dir
|
|
134
158
|
)
|
|
135
159
|
|
|
@@ -139,7 +163,7 @@ class InstallService:
|
|
|
139
163
|
for src in sorted(skills_dir.glob("r2p-*/SKILL.md")):
|
|
140
164
|
dest = platform_home / "skills" / src.parent.name / "SKILL.md"
|
|
141
165
|
content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
|
|
142
|
-
|
|
166
|
+
self._write_managed_file(
|
|
143
167
|
dest, content, backups, installed_paths, written, backup_dir
|
|
144
168
|
)
|
|
145
169
|
|
|
@@ -149,7 +173,7 @@ class InstallService:
|
|
|
149
173
|
for src in sorted(cmd_dir.glob("r2p-*.toml")):
|
|
150
174
|
dest = platform_home / "commands" / src.name
|
|
151
175
|
content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
|
|
152
|
-
|
|
176
|
+
self._write_managed_file(
|
|
153
177
|
dest, content, backups, installed_paths, written, backup_dir
|
|
154
178
|
)
|
|
155
179
|
|
|
@@ -162,8 +186,10 @@ class InstallService:
|
|
|
162
186
|
"r2p_version": R2P_VERSION,
|
|
163
187
|
"schema_version": SCHEMA_VERSION,
|
|
164
188
|
}
|
|
189
|
+
self._validate_install_path(manifest_path, field="manifest")
|
|
165
190
|
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
191
|
manifest_path.write_text(_dump_manifest(manifest), encoding="utf-8")
|
|
192
|
+
manifest_written = True
|
|
167
193
|
|
|
168
194
|
# Remove obsolete managed shared wrappers (e.g. a 0.1.2 r2p-adapt) that
|
|
169
195
|
# are no longer part of the current template set, across all manifests.
|
|
@@ -172,6 +198,12 @@ class InstallService:
|
|
|
172
198
|
|
|
173
199
|
except Exception:
|
|
174
200
|
# Rollback: remove written files, restore backups
|
|
201
|
+
if manifest_written:
|
|
202
|
+
# A pre-existing manifest is impossible here: a fresh install
|
|
203
|
+
# never had one, and a reinstall's uninstall already removed
|
|
204
|
+
# it — prior-manifest restoration is owned by
|
|
205
|
+
# _restore_install_snapshot below.
|
|
206
|
+
manifest_path.unlink(missing_ok=True)
|
|
175
207
|
for path in reversed(written):
|
|
176
208
|
try:
|
|
177
209
|
path.unlink(missing_ok=True)
|
|
@@ -183,6 +215,9 @@ class InstallService:
|
|
|
183
215
|
if backup_path.exists():
|
|
184
216
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
217
|
shutil.copy2(str(backup_path), str(target_path))
|
|
218
|
+
backup_path.unlink(missing_ok=True)
|
|
219
|
+
if prior_install is not None:
|
|
220
|
+
self._restore_install_snapshot(prior_install)
|
|
186
221
|
raise
|
|
187
222
|
|
|
188
223
|
def uninstall(self, platform: str) -> dict:
|
|
@@ -488,31 +523,39 @@ class InstallService:
|
|
|
488
523
|
if platform not in SUPPORTED_PLATFORMS:
|
|
489
524
|
raise ValueError(f"unsafe_manifest: unsupported_platform {platform!r}")
|
|
490
525
|
|
|
526
|
+
targets = set(self._install_targets(platform))
|
|
527
|
+
bin_dir = self.manifest_root / "bin"
|
|
528
|
+
targets.update(bin_dir / name for name in KNOWN_OBSOLETE_SHARED_WRAPPERS)
|
|
529
|
+
|
|
530
|
+
platform_home = self.platform_homes[platform]
|
|
531
|
+
for parts in KNOWN_OBSOLETE_PLATFORM_TARGETS.get(platform, ()):
|
|
532
|
+
targets.add(platform_home.joinpath(*parts))
|
|
533
|
+
|
|
534
|
+
return targets
|
|
535
|
+
|
|
536
|
+
def _install_targets(self, platform: str) -> list[Path]:
|
|
491
537
|
bin_dir = self.manifest_root / "bin"
|
|
492
|
-
targets =
|
|
538
|
+
targets = [
|
|
493
539
|
bin_dir / src.name
|
|
494
540
|
for src in sorted(self.repo_root.glob("tools/r2p-*"))
|
|
495
541
|
if src.is_file()
|
|
496
|
-
|
|
497
|
-
targets.update(bin_dir / name for name in KNOWN_OBSOLETE_SHARED_WRAPPERS)
|
|
542
|
+
]
|
|
498
543
|
|
|
499
544
|
template_dir = (
|
|
500
545
|
self.repo_root / "tools" / "workflow_cli" / "agent_templates" / platform
|
|
501
546
|
)
|
|
502
547
|
platform_home = self.platform_homes[platform]
|
|
503
|
-
for parts in KNOWN_OBSOLETE_PLATFORM_TARGETS.get(platform, ()):
|
|
504
|
-
targets.add(platform_home.joinpath(*parts))
|
|
505
548
|
|
|
506
549
|
if platform == "claude":
|
|
507
|
-
targets.
|
|
550
|
+
targets.append(platform_home / "skills" / "r2p" / "SKILL.md")
|
|
508
551
|
for src in sorted((template_dir / "commands").glob("r2p-*.md")):
|
|
509
|
-
targets.
|
|
552
|
+
targets.append(platform_home / "commands" / src.name)
|
|
510
553
|
elif platform == "codex":
|
|
511
554
|
for src in sorted((template_dir / "skills").glob("r2p-*/SKILL.md")):
|
|
512
|
-
targets.
|
|
555
|
+
targets.append(platform_home / "skills" / src.parent.name / "SKILL.md")
|
|
513
556
|
elif platform == "gemini":
|
|
514
557
|
for src in sorted((template_dir / "commands").glob("r2p-*.toml")):
|
|
515
|
-
targets.
|
|
558
|
+
targets.append(platform_home / "commands" / src.name)
|
|
516
559
|
|
|
517
560
|
return targets
|
|
518
561
|
|
|
@@ -703,6 +746,99 @@ class InstallService:
|
|
|
703
746
|
except (OSError, UnicodeDecodeError, ValueError):
|
|
704
747
|
return None
|
|
705
748
|
|
|
749
|
+
def _capture_install_snapshot(
|
|
750
|
+
self,
|
|
751
|
+
platform: str,
|
|
752
|
+
manifest_path: Path,
|
|
753
|
+
) -> _InstallSnapshot:
|
|
754
|
+
manifest = _load_manifest(manifest_path)
|
|
755
|
+
self._validate_manifest_for_uninstall(platform, manifest)
|
|
756
|
+
files: list[_FileSnapshot] = []
|
|
757
|
+
backup_files: list[_FileSnapshot] = []
|
|
758
|
+
|
|
759
|
+
for path_str in manifest.get("installed_paths", []):
|
|
760
|
+
snapshot = self._snapshot_file(Path(path_str))
|
|
761
|
+
if snapshot is not None:
|
|
762
|
+
files.append(snapshot)
|
|
763
|
+
|
|
764
|
+
for backup in manifest.get("backups", []):
|
|
765
|
+
snapshot = self._snapshot_file(Path(backup["backup"]))
|
|
766
|
+
if snapshot is not None:
|
|
767
|
+
backup_files.append(snapshot)
|
|
768
|
+
|
|
769
|
+
return _InstallSnapshot(
|
|
770
|
+
manifest_path=manifest_path,
|
|
771
|
+
manifest_text=manifest_path.read_text(encoding="utf-8"),
|
|
772
|
+
files=files,
|
|
773
|
+
backup_files=backup_files,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
def _snapshot_file(self, path: Path) -> _FileSnapshot | None:
|
|
777
|
+
if not path.is_file():
|
|
778
|
+
return None
|
|
779
|
+
return _FileSnapshot(
|
|
780
|
+
path=path,
|
|
781
|
+
content=path.read_bytes(),
|
|
782
|
+
mode=stat.S_IMODE(path.stat().st_mode),
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
def _restore_install_snapshot(self, snapshot: _InstallSnapshot) -> None:
|
|
786
|
+
for file_snapshot in snapshot.files + snapshot.backup_files:
|
|
787
|
+
file_snapshot.path.parent.mkdir(parents=True, exist_ok=True)
|
|
788
|
+
file_snapshot.path.write_bytes(file_snapshot.content)
|
|
789
|
+
try:
|
|
790
|
+
file_snapshot.path.chmod(file_snapshot.mode)
|
|
791
|
+
except OSError:
|
|
792
|
+
pass
|
|
793
|
+
snapshot.manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
794
|
+
snapshot.manifest_path.write_text(snapshot.manifest_text, encoding="utf-8")
|
|
795
|
+
|
|
796
|
+
def _validate_install_path(self, path: Path, *, field: str) -> None:
|
|
797
|
+
"""Reject install writes that would follow untrusted symlinks."""
|
|
798
|
+
if path.is_symlink():
|
|
799
|
+
raise ValueError(f"unsafe_install: {field}_is_symlink")
|
|
800
|
+
normalized = self._normalize_without_resolving_symlinks(path)
|
|
801
|
+
try:
|
|
802
|
+
self._reject_symlinked_ancestors(normalized, field=field)
|
|
803
|
+
except ValueError as exc:
|
|
804
|
+
raise ValueError(
|
|
805
|
+
str(exc).replace("unsafe_manifest:", "unsafe_install:", 1)
|
|
806
|
+
) from exc
|
|
807
|
+
|
|
808
|
+
def _validate_install_backup_dir(self, backup_dir: Path) -> None:
|
|
809
|
+
probe = backup_dir / "__r2p_probe__"
|
|
810
|
+
normalized = self._normalize_without_resolving_symlinks(probe)
|
|
811
|
+
try:
|
|
812
|
+
self._reject_symlinked_ancestors(normalized, field="backup_dir")
|
|
813
|
+
except ValueError as exc:
|
|
814
|
+
raise ValueError(
|
|
815
|
+
str(exc).replace("unsafe_manifest:", "unsafe_install:", 1)
|
|
816
|
+
) from exc
|
|
817
|
+
|
|
818
|
+
def _validate_install_plan(
|
|
819
|
+
self,
|
|
820
|
+
platform: str,
|
|
821
|
+
manifest_path: Path,
|
|
822
|
+
backup_dir: Path,
|
|
823
|
+
) -> None:
|
|
824
|
+
for target in self._install_targets(platform):
|
|
825
|
+
self._validate_install_path(target, field="install_destination")
|
|
826
|
+
self._validate_install_path(manifest_path, field="manifest")
|
|
827
|
+
self._validate_install_backup_dir(backup_dir)
|
|
828
|
+
|
|
829
|
+
def _write_managed_file(
|
|
830
|
+
self,
|
|
831
|
+
dest: Path,
|
|
832
|
+
content: str,
|
|
833
|
+
backups: list[dict[str, str]],
|
|
834
|
+
installed_paths: list[str],
|
|
835
|
+
written: list[Path],
|
|
836
|
+
backup_dir: Path,
|
|
837
|
+
) -> None:
|
|
838
|
+
self._validate_install_path(dest, field="install_destination")
|
|
839
|
+
self._validate_install_backup_dir(backup_dir)
|
|
840
|
+
_safe_write(dest, content, backups, installed_paths, written, backup_dir)
|
|
841
|
+
|
|
706
842
|
|
|
707
843
|
# ---------------------------------------------------------------------------
|
|
708
844
|
# Internal helpers
|
|
@@ -1 +1 @@
|
|
|
1
|
-
R2P_VERSION = "0.4.
|
|
1
|
+
R2P_VERSION = "0.4.4"
|