@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenonbyte/req-2-plan",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Requirement-to-PLAN workflow CLI and agent integration installer.",
5
5
  "bin": {
6
6
  "r2p": "bin/r2p.js"
@@ -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
- are exempt; the part after '::' (a symbol) is advisory and not checked (no AST pack yet)."""
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
- skip_missing_path = _normalized_change_type(_task_change_type(body)) == "create"
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 skip_missing_path:
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
- if manifest_path.exists():
88
- self.uninstall(platform)
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
- backup_dir = self.manifest_root / "install" / "backups" / platform
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
- _safe_write(
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
- _safe_write(
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
- _safe_write(
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
- _safe_write(
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
- _safe_write(
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.add(platform_home / "skills" / "r2p" / "SKILL.md")
550
+ targets.append(platform_home / "skills" / "r2p" / "SKILL.md")
508
551
  for src in sorted((template_dir / "commands").glob("r2p-*.md")):
509
- targets.add(platform_home / "commands" / src.name)
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.add(platform_home / "skills" / src.parent.name / "SKILL.md")
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.add(platform_home / "commands" / src.name)
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.2"
1
+ R2P_VERSION = "0.4.4"