@xenonbyte/req-2-plan 0.4.1 → 0.4.3

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.1",
3
+ "version": "0.4.3",
4
4
  "description": "Requirement-to-PLAN workflow CLI and agent integration installer.",
5
5
  "bin": {
6
6
  "r2p": "bin/r2p.js"
@@ -3,7 +3,7 @@ description: Start a new requirement-to-PLAN workflow run
3
3
  ---
4
4
  Run `{{R2P_BIN_DIR}}/r2p-start` with the requirement as a positional argument.
5
5
 
6
- Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] ("<raw requirement>" | --file <path>)`
6
+ Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] [--repo-path <dir>] ("<raw requirement>" | --file <path>)`
7
7
 
8
8
  To start from a requirement document, pass `--file <path>` instead of inline text — r2p reads the file contents as the requirement (the path itself is never stored as the requirement): `{{R2P_BIN_DIR}}/r2p-start [--separate] --file ./requirement.md`. `--file` and a positional requirement are mutually exclusive.
9
9
 
@@ -7,7 +7,7 @@ description: Start a new requirement-to-PLAN workflow run from a raw requirement
7
7
 
8
8
  Run `{{R2P_BIN_DIR}}/r2p-start` with the requirement as a positional argument.
9
9
 
10
- Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] ("<raw requirement>" | --file <path>)`
10
+ Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] [--repo-path <dir>] ("<raw requirement>" | --file <path>)`
11
11
 
12
12
  To start from a requirement document, pass `--file <path>` instead of inline text — r2p reads the file contents as the requirement (the path itself is never stored as the requirement): `{{R2P_BIN_DIR}}/r2p-start [--separate] --file ./requirement.md`. `--file` and a positional requirement are mutually exclusive.
13
13
 
@@ -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 = {
@@ -27,6 +32,53 @@ class ProjectContextPack:
27
32
  source_dirs: list = field(default_factory=list)
28
33
 
29
34
 
35
+ def _append_npm_dependencies(pack: ProjectContextPack, dependencies: object, *, dev: bool = False) -> None:
36
+ if not isinstance(dependencies, dict):
37
+ return
38
+ for name, version in dependencies.items():
39
+ if not isinstance(name, str) or not isinstance(version, str):
40
+ continue
41
+ dep = {"name": name, "version": version, "ecosystem": "npm"}
42
+ if dev:
43
+ dep["dev"] = True
44
+ pack.dependencies.append(dep)
45
+
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
+
30
82
  def build_context_pack(repo_path: Path) -> ProjectContextPack:
31
83
  repo_path = Path(repo_path).resolve()
32
84
  baseline = scan_repo_baseline(repo_path)
@@ -41,15 +93,8 @@ def build_context_pack(repo_path: Path) -> ProjectContextPack:
41
93
  scripts = data.get("scripts")
42
94
  if isinstance(scripts, dict) and scripts.get("test"):
43
95
  pack.test_commands.append("npm test")
44
- dependencies = data.get("dependencies")
45
- if isinstance(dependencies, dict):
46
- for name, ver in dependencies.items():
47
- pack.dependencies.append({"name": name, "version": ver, "ecosystem": "npm"})
48
- dev_dependencies = data.get("devDependencies")
49
- if isinstance(dev_dependencies, dict):
50
- for name, ver in dev_dependencies.items():
51
- pack.dependencies.append(
52
- {"name": name, "version": ver, "ecosystem": "npm", "dev": True})
96
+ _append_npm_dependencies(pack, data.get("dependencies"))
97
+ _append_npm_dependencies(pack, data.get("devDependencies"), dev=True)
53
98
  except (ValueError, OSError):
54
99
  pass
55
100
 
@@ -63,6 +108,8 @@ def build_context_pack(repo_path: Path) -> ProjectContextPack:
63
108
  if not pack.test_commands:
64
109
  pack.test_commands.append("python -m pytest")
65
110
 
111
+ _append_pyproject_facts(pack, repo_path)
112
+
66
113
  for root, dirs, files in os.walk(repo_path):
67
114
  dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
68
115
  rel_root = Path(root).relative_to(repo_path)
@@ -84,6 +131,25 @@ def to_json(pack: ProjectContextPack) -> str:
84
131
  return json.dumps(asdict(pack), indent=2, ensure_ascii=False)
85
132
 
86
133
 
134
+ _DEP_DISPLAY_CAP = 20
135
+
136
+
137
+ def _dependencies_markdown(dependencies: list) -> str:
138
+ """Itemize dependencies (name, version, ecosystem, dev flag), capped to keep the seed small."""
139
+ if not dependencies:
140
+ return "- dependencies (0): none\n"
141
+ lines = [f"- dependencies ({len(dependencies)}):\n"]
142
+ for dep in dependencies[:_DEP_DISPLAY_CAP]:
143
+ label = " ".join(part for part in (dep.get("name", ""), dep.get("version", "")) if part)
144
+ ecosystem = dep.get("ecosystem", "")
145
+ suffix = f"{ecosystem}, dev" if dep.get("dev") else ecosystem
146
+ lines.append(f" - {label} ({suffix})\n")
147
+ hidden = len(dependencies) - _DEP_DISPLAY_CAP
148
+ if hidden > 0:
149
+ lines.append(f" - … and {hidden} more\n")
150
+ return "".join(lines)
151
+
152
+
87
153
  def to_markdown(pack: ProjectContextPack) -> str:
88
154
  return (
89
155
  "# Project Context Pack\n\n"
@@ -93,7 +159,7 @@ def to_markdown(pack: ProjectContextPack) -> str:
93
159
  f"- test_commands: {pack.test_commands or 'none'}\n"
94
160
  f"- entrypoints: {pack.entrypoints or 'none'}\n"
95
161
  f"- config_files: {pack.config_files or 'none'}\n"
96
- f"- dependencies: {len(pack.dependencies)} found\n"
162
+ f"{_dependencies_markdown(pack.dependencies)}"
97
163
  f"- source_dirs: {pack.source_dirs}\n"
98
164
  )
99
165
 
@@ -290,14 +290,6 @@ def _find_next_plan_task_field_start(task_body: str, after: int) -> int | None:
290
290
  return None
291
291
 
292
292
 
293
- def _plan_task_field_value(task_body: str, field: str) -> str:
294
- found = _find_plan_task_field(task_body, field)
295
- if found is None:
296
- return ""
297
- match, _ = found
298
- return match.group(1).strip()
299
-
300
-
301
293
  def _iter_plan_task_bodies(content: str):
302
294
  return heading_bounded_bodies(content, _PLAN_TASK_RE.match)
303
295
 
@@ -439,13 +431,14 @@ def _check_plan_context_pack(run_dir: Path) -> list[str]:
439
431
 
440
432
  def _check_plan_file_refs(run_dir: Path, content: str) -> list[str]:
441
433
  """Hard-check Files paths against the Context Pack repo_root. create-type tasks
442
- 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)."""
443
436
  repo_root = _context_pack_repo_root(run_dir)
444
437
  if repo_root is None:
445
438
  return [] # no usable ground truth; standard tier blocks via _check_plan_context_pack
446
439
  issues: list[str] = []
447
440
  for body in _iter_plan_task_bodies(content):
448
- skip_missing_path = _normalized_change_type(_task_change_type(body)) == "create"
441
+ is_create = _normalized_change_type(_task_change_type(body)) == "create"
449
442
  files_field = _plan_task_field_body(body, "Files")
450
443
  for path_part in _plan_task_file_paths(files_field):
451
444
  path = Path(path_part)
@@ -461,11 +454,18 @@ def _check_plan_file_refs(run_dir: Path, content: str) -> list[str]:
461
454
  )
462
455
  continue
463
456
  if not resolved.exists():
464
- if not skip_missing_path:
457
+ if not is_create:
465
458
  issues.append(
466
459
  f"PLAN-TASK Files references missing path {path_part!r} "
467
460
  "(mark the task 'Change Type: create' if it is a new file)."
468
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
+ )
469
469
  return issues
470
470
 
471
471
 
@@ -496,8 +496,10 @@ def _check_spec_refs_valid(run_dir: Path, content: str) -> list[str]:
496
496
 
497
497
 
498
498
  # R10: Change Type is a closed operation-kind enum; 'new' is a legacy alias.
499
- _CHANGE_TYPE_VALUES = frozenset({"create", "modify", "delete"})
499
+ _FILE_CHANGE_TYPES = frozenset({"create", "modify", "delete"})
500
+ _CHANGE_TYPE_VALUES = _FILE_CHANGE_TYPES | {"non_code"}
500
501
  _CHANGE_TYPE_ALIASES = {"new": "create"}
502
+ _TDD_APPLICABLE_VALUES = frozenset({"yes", "no"})
501
503
 
502
504
 
503
505
  def _normalized_change_type(raw: str) -> str:
@@ -510,6 +512,11 @@ def _task_change_type(body: str) -> str:
510
512
  return " ".join(_plan_task_field_body(body, "Change Type").split())
511
513
 
512
514
 
515
+ def _task_tdd_applicable(body: str) -> str:
516
+ """Whitespace-normalized TDD Applicable field body (same line + continuation lines)."""
517
+ return " ".join(_plan_task_field_body(body, "TDD Applicable").split())
518
+
519
+
513
520
  def _check_plan_task_fields(content: str) -> list[str]:
514
521
  issues: list[str] = []
515
522
  numbers: list[int] = []
@@ -523,10 +530,32 @@ def _check_plan_task_fields(content: str) -> list[str]:
523
530
  if not _plan_task_field_body(body, field).strip():
524
531
  issues.append(f"{label} is missing a non-empty '{field}:' field.")
525
532
  raw_change_type = _task_change_type(body)
526
- if raw_change_type and _normalized_change_type(raw_change_type) not in _CHANGE_TYPE_VALUES:
533
+ change_type = _normalized_change_type(raw_change_type)
534
+ if raw_change_type and change_type not in _CHANGE_TYPE_VALUES:
527
535
  issues.append(
528
536
  f"{label} has invalid 'Change Type: {raw_change_type}'; "
529
- "allowed: create|modify|delete (alias: new = create)."
537
+ "allowed: create|modify|delete|non_code (alias: new = create)."
538
+ )
539
+ # R16: Change Type and Files must agree — file-op types need a real
540
+ # path; non_code is the only legal shape for no-file tasks.
541
+ files_body = _plan_task_field_body(body, "Files")
542
+ if files_body.strip():
543
+ file_paths = _plan_task_file_paths(files_body)
544
+ if change_type in _FILE_CHANGE_TYPES and not file_paths:
545
+ issues.append(
546
+ f"{label} has 'Change Type: {raw_change_type}' but 'Files:' lists "
547
+ "no real file path; use 'Change Type: non_code' for tasks that "
548
+ "touch no files."
549
+ )
550
+ elif change_type == "non_code" and file_paths:
551
+ issues.append(
552
+ f"{label} has 'Change Type: non_code' but 'Files:' lists a file path; "
553
+ "use create|modify|delete for file-touching tasks."
554
+ )
555
+ raw_tdd = _task_tdd_applicable(body)
556
+ if raw_tdd and raw_tdd.lower() not in _TDD_APPLICABLE_VALUES:
557
+ issues.append(
558
+ f"{label} has invalid 'TDD Applicable: {raw_tdd}'; allowed: yes|no."
530
559
  )
531
560
  if numbers:
532
561
  if len(set(numbers)) != len(numbers):
@@ -575,8 +604,7 @@ def _plan_tasks_missing_code(content: str) -> bool:
575
604
  return False
576
605
  for body in bodies:
577
606
  skeleton = _plan_task_field_body(body, "Skeleton")
578
- tdd_applicable = _plan_task_field_value(body, "TDD Applicable")
579
- if tdd_applicable.lower() == "yes" and not _has_complete_code_fence(skeleton):
607
+ if _task_tdd_applicable(body).lower() == "yes" and not _has_complete_code_fence(skeleton):
580
608
  return True
581
609
  return False
582
610
 
@@ -1 +1 @@
1
- R2P_VERSION = "0.4.1"
1
+ R2P_VERSION = "0.4.3"