@danmoisan/drm-copilot-mcp 0.0.6 → 1.0.0
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/out/mcp-server.js +10213 -398
- package/package.json +2 -2
- package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +127 -17
- package/resources/claude-customizations/.claude/hooks/enforce-completion-helpers.ps1 +163 -0
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +0 -12
- package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +68 -1
- package/resources/config/orchestration-routing.json +3 -3
- package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +0 -5
- package/resources/scripts/dev_tools/__init__.py +0 -0
- package/resources/scripts/dev_tools/_orchestrator_state_human_interaction.py +0 -127
- package/resources/scripts/dev_tools/_orchestrator_state_routing.py +0 -216
- package/resources/scripts/dev_tools/agentic_sync.py +0 -819
- package/resources/scripts/dev_tools/codex_native_converter/__init__.py +0 -11
- package/resources/scripts/dev_tools/codex_native_converter/__main__.py +0 -6
- package/resources/scripts/dev_tools/codex_native_converter/cli.py +0 -11
- package/resources/scripts/dev_tools/new_active_feature_folder.py +0 -79
- package/resources/scripts/dev_tools/new_active_feature_folder_docs.py +0 -268
- package/resources/scripts/dev_tools/new_active_feature_folder_flow.py +0 -366
- package/resources/scripts/dev_tools/new_active_feature_folder_io.py +0 -306
- package/resources/scripts/dev_tools/new_active_feature_folder_markdown.py +0 -252
- package/resources/scripts/dev_tools/new_active_feature_folder_models.py +0 -136
- package/resources/scripts/dev_tools/new_potential_bug_entry.py +0 -465
- package/resources/scripts/dev_tools/potential_to_issue.py +0 -421
- package/resources/scripts/dev_tools/potential_to_issue_content.py +0 -212
- package/resources/scripts/dev_tools/pr_context/__init__.py +0 -0
- package/resources/scripts/dev_tools/pr_context/collector.py +0 -619
- package/resources/scripts/dev_tools/pr_context/feature_docs.py +0 -349
- package/resources/scripts/dev_tools/pr_context/git.py +0 -153
- package/resources/scripts/dev_tools/pr_context/github.py +0 -549
- package/resources/scripts/dev_tools/pr_context/models.py +0 -198
- package/resources/scripts/dev_tools/pr_context/render.py +0 -342
- package/resources/scripts/dev_tools/pr_context/render_feature_excerpts.py +0 -256
- package/resources/scripts/dev_tools/pr_context/render_pr_helpers.py +0 -291
- package/resources/scripts/dev_tools/pr_context/summary_helpers.py +0 -386
- package/resources/scripts/dev_tools/pr_context/verification_evidence.py +0 -171
- package/resources/scripts/dev_tools/prompt_mode_contract.py +0 -152
- package/resources/scripts/dev_tools/push_down_claude_customizations.py +0 -403
- package/resources/scripts/dev_tools/push_down_claude_filesystem.py +0 -472
- package/resources/scripts/dev_tools/push_down_claude_pack_selection.py +0 -401
- package/resources/scripts/dev_tools/push_down_codex_and_agents_customizations.py +0 -139
- package/resources/scripts/dev_tools/push_down_copilot_customizations.py +0 -504
- package/resources/scripts/dev_tools/push_down_copilot_customizations_filesystem.py +0 -217
- package/resources/scripts/dev_tools/push_down_copilot_customizations_rewrites.py +0 -293
- package/resources/scripts/dev_tools/resolve_file_prompt.py +0 -457
- package/resources/scripts/dev_tools/resolve_hard_lock_prompt.py +0 -444
- package/resources/scripts/dev_tools/validate_orchestration_artifacts.py +0 -246
- package/resources/scripts/dev_tools/validate_orchestration_review_artifacts.py +0 -107
- package/resources/scripts/dev_tools/validate_orchestrator_state.py +0 -505
- package/resources/scripts/dev_tools/validate_policy_audit_artifact.py +0 -472
- package/resources/templates/codex_native_converter.py +0 -35
- package/resources/templates/collect_commit_context.py +0 -212
- package/resources/templates/collect_pr_context.py +0 -74
- package/resources/templates/hello_python.py +0 -11
- package/resources/templates/new_active_feature_folder.py +0 -67
- package/resources/templates/new_potential_bug_entry.py +0 -54
- package/resources/templates/potential_to_issue.py +0 -55
- package/resources/templates/push_down_claude_customizations.py +0 -233
- package/resources/templates/push_down_codex_and_agents_customizations.py +0 -95
- package/resources/templates/push_down_copilot_customizations.py +0 -124
- package/resources/templates/resolve_atomic_plan_prompt.py +0 -75
- package/resources/templates/resolve_hard_lock_prompt.py +0 -65
- package/resources/templates/validate_orchestration_artifacts.py +0 -55
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
"""Markdown transformation helpers for active feature folder creation."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import re
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
7
|
-
|
|
8
|
-
from dev_tools.new_active_feature_folder_models import PLACEHOLDERS
|
|
9
|
-
|
|
10
|
-
if TYPE_CHECKING:
|
|
11
|
-
from collections.abc import Callable
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def format_checklist(text: str) -> str:
|
|
15
|
-
"""Normalize freeform checklist text into markdown checkboxes."""
|
|
16
|
-
lines: list[str] = []
|
|
17
|
-
for raw_line in text.splitlines():
|
|
18
|
-
trimmed = raw_line.strip()
|
|
19
|
-
if not trimmed:
|
|
20
|
-
continue
|
|
21
|
-
if re.match(r"^-\s*\[?\s*\]", trimmed):
|
|
22
|
-
lines.append(trimmed)
|
|
23
|
-
elif trimmed.startswith("-"):
|
|
24
|
-
lines.append(trimmed)
|
|
25
|
-
else:
|
|
26
|
-
lines.append(f"- [ ] {trimmed}")
|
|
27
|
-
return "\n".join(lines)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def get_section(content: str, name: str) -> str:
|
|
31
|
-
"""Extract a markdown section body by heading."""
|
|
32
|
-
pattern = re.compile(
|
|
33
|
-
rf"^\s*##\s+{re.escape(name)}\s*\r?\n(.*?)(?=^\s*##\s+|\Z)",
|
|
34
|
-
re.DOTALL | re.MULTILINE,
|
|
35
|
-
)
|
|
36
|
-
match = pattern.search(content)
|
|
37
|
-
if not match:
|
|
38
|
-
return ""
|
|
39
|
-
return match.group(1).strip()
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def upsert_work_mode_marker(content: str, mode: str) -> str:
|
|
43
|
-
"""Insert or update work-mode marker directly above first `##` heading."""
|
|
44
|
-
marker_line = f"- Work Mode: {mode}"
|
|
45
|
-
marker_pattern = re.compile(
|
|
46
|
-
r"^- Work Mode:\s*(minor-audit|full-feature|full-bug|full)\s*$"
|
|
47
|
-
)
|
|
48
|
-
lines = [line for line in content.splitlines() if not marker_pattern.match(line)]
|
|
49
|
-
|
|
50
|
-
for idx, line in enumerate(lines):
|
|
51
|
-
if line.lstrip().startswith("## "):
|
|
52
|
-
lines.insert(idx, "")
|
|
53
|
-
lines.insert(idx, marker_line)
|
|
54
|
-
return "\n".join(lines)
|
|
55
|
-
|
|
56
|
-
if lines and lines[-1] != "":
|
|
57
|
-
lines.append("")
|
|
58
|
-
lines.append(marker_line)
|
|
59
|
-
return "\n".join(lines)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def set_section(content: str, name: str, body: str) -> str:
|
|
63
|
-
"""Set or append a markdown section body."""
|
|
64
|
-
if not body or not body.strip():
|
|
65
|
-
return content
|
|
66
|
-
|
|
67
|
-
pattern = re.compile(
|
|
68
|
-
rf"(^##\s+{re.escape(name)}\s*\r?\n)(.*?)(?=^\s*##\s+|\Z)",
|
|
69
|
-
re.DOTALL | re.MULTILINE,
|
|
70
|
-
)
|
|
71
|
-
if pattern.search(content):
|
|
72
|
-
return pattern.sub(
|
|
73
|
-
lambda match: f"{match.group(1)}{body}\n\n",
|
|
74
|
-
content,
|
|
75
|
-
count=1,
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
trimmed = content.rstrip()
|
|
79
|
-
if trimmed:
|
|
80
|
-
trimmed += "\n\n"
|
|
81
|
-
return f"{trimmed}## {name}\n{body}\n"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def prepend_to_section_body(section_body: str, prefix: str) -> str:
|
|
85
|
-
"""Prepend text to an existing section body while preserving content."""
|
|
86
|
-
trimmed_prefix = prefix.strip()
|
|
87
|
-
if not trimmed_prefix:
|
|
88
|
-
return section_body
|
|
89
|
-
|
|
90
|
-
trimmed_body = section_body.strip()
|
|
91
|
-
if not trimmed_body:
|
|
92
|
-
return f"{trimmed_prefix}\n"
|
|
93
|
-
return f"{trimmed_prefix}\n\n{trimmed_body}\n"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def update_section_body(
|
|
97
|
-
content: str,
|
|
98
|
-
section_name: str,
|
|
99
|
-
updater: Callable[[str], str],
|
|
100
|
-
) -> tuple[str, bool]:
|
|
101
|
-
"""Update a `##` section body using a transformation function."""
|
|
102
|
-
pattern = re.compile(
|
|
103
|
-
rf"(^##\s+{re.escape(section_name)}\s*\r?\n)(.*?)(?=^\s*##\s+|\Z)",
|
|
104
|
-
re.DOTALL | re.MULTILINE,
|
|
105
|
-
)
|
|
106
|
-
match = pattern.search(content)
|
|
107
|
-
if not match:
|
|
108
|
-
return content, False
|
|
109
|
-
|
|
110
|
-
header = match.group(1)
|
|
111
|
-
body = match.group(2)
|
|
112
|
-
updated_body = updater(body)
|
|
113
|
-
if updated_body == body:
|
|
114
|
-
return content, False
|
|
115
|
-
|
|
116
|
-
return (
|
|
117
|
-
pattern.sub(
|
|
118
|
-
lambda _match: f"{header}{updated_body}\n",
|
|
119
|
-
content,
|
|
120
|
-
count=1,
|
|
121
|
-
),
|
|
122
|
-
True,
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def set_header_placeholder(
|
|
127
|
-
content: str,
|
|
128
|
-
feature_name: str,
|
|
129
|
-
issue_field: str,
|
|
130
|
-
owner_field: str,
|
|
131
|
-
updated_field: str,
|
|
132
|
-
status_field: str | None = None,
|
|
133
|
-
parent_field: str | None = None,
|
|
134
|
-
version_field: str | None = None,
|
|
135
|
-
) -> str:
|
|
136
|
-
"""Replace template placeholders in the frontmatter/header block."""
|
|
137
|
-
result = content
|
|
138
|
-
for placeholder in PLACEHOLDERS:
|
|
139
|
-
result = result.replace(placeholder, feature_name)
|
|
140
|
-
result = result.replace("<issue>", issue_field)
|
|
141
|
-
if parent_field is not None:
|
|
142
|
-
result = result.replace("<parent-id>", parent_field)
|
|
143
|
-
if status_field is not None:
|
|
144
|
-
result = result.replace("<status>", status_field)
|
|
145
|
-
if version_field is not None:
|
|
146
|
-
result = result.replace("<version_number>", version_field)
|
|
147
|
-
result = re.sub(r"#`?<id>`?", issue_field, result)
|
|
148
|
-
result = result.replace("<#id or TBD>", issue_field)
|
|
149
|
-
result = result.replace("#<tracking-issue>", issue_field)
|
|
150
|
-
|
|
151
|
-
result = re.sub(
|
|
152
|
-
r"^-\s*\*\*Issue:\*\*\s+.*$",
|
|
153
|
-
f"- **Issue:** {issue_field}",
|
|
154
|
-
result,
|
|
155
|
-
flags=re.MULTILINE,
|
|
156
|
-
)
|
|
157
|
-
result = re.sub(
|
|
158
|
-
r"^-\s*Issue\s*:\s+.*$",
|
|
159
|
-
f"- Issue: {issue_field}",
|
|
160
|
-
result,
|
|
161
|
-
flags=re.MULTILINE,
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
result = re.sub(
|
|
165
|
-
r"^-\s*\*\*Owner:\*\*\s+(?:name|<name>|.*)$",
|
|
166
|
-
f"- **Owner:** {owner_field}",
|
|
167
|
-
result,
|
|
168
|
-
flags=re.MULTILINE,
|
|
169
|
-
)
|
|
170
|
-
result = re.sub(
|
|
171
|
-
r"^-\s*Owner\s*:\s+(?:name|<name>|.*)$",
|
|
172
|
-
f"- Owner: {owner_field}",
|
|
173
|
-
result,
|
|
174
|
-
flags=re.MULTILINE,
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
if parent_field is not None:
|
|
178
|
-
result = re.sub(
|
|
179
|
-
r"^-\s*\*\*Parent \(optional\):\*\*\s+.*$",
|
|
180
|
-
f"- **Parent (optional):** {parent_field}",
|
|
181
|
-
result,
|
|
182
|
-
flags=re.MULTILINE,
|
|
183
|
-
)
|
|
184
|
-
result = re.sub(
|
|
185
|
-
r"^-\s*Parent \(optional\)\s*:\s+.*$",
|
|
186
|
-
f"- Parent (optional): {parent_field}",
|
|
187
|
-
result,
|
|
188
|
-
flags=re.MULTILINE,
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
result = re.sub(
|
|
192
|
-
r"^-\s*\*\*Last Updated:\*\*\s+.*$",
|
|
193
|
-
f"- **Last Updated:** {updated_field}",
|
|
194
|
-
result,
|
|
195
|
-
flags=re.MULTILINE,
|
|
196
|
-
)
|
|
197
|
-
result = re.sub(
|
|
198
|
-
r"^-\s*Last Updated\s*:\s+.*$",
|
|
199
|
-
f"- Last Updated: {updated_field}",
|
|
200
|
-
result,
|
|
201
|
-
flags=re.MULTILINE,
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
result = re.sub(
|
|
205
|
-
r"^-\s*\*\*Date:\*\*\s+.*$",
|
|
206
|
-
f"- **Date:** {updated_field}",
|
|
207
|
-
result,
|
|
208
|
-
flags=re.MULTILINE,
|
|
209
|
-
)
|
|
210
|
-
result = re.sub(
|
|
211
|
-
r"^-\s*Date\s*:\s+YYYY-MM-DD$",
|
|
212
|
-
f"- Date: {updated_field}",
|
|
213
|
-
result,
|
|
214
|
-
flags=re.MULTILINE,
|
|
215
|
-
)
|
|
216
|
-
result = result.replace("<yyyy-MM-ddTHH-mm>", updated_field)
|
|
217
|
-
|
|
218
|
-
if status_field is not None:
|
|
219
|
-
result = re.sub(
|
|
220
|
-
r"^-\s*\*\*Status:\*\*\s+.*$",
|
|
221
|
-
f"- **Status:** {status_field}",
|
|
222
|
-
result,
|
|
223
|
-
flags=re.MULTILINE,
|
|
224
|
-
)
|
|
225
|
-
result = re.sub(
|
|
226
|
-
r"^-\s*Status\s*:\s+.*$",
|
|
227
|
-
f"- Status: {status_field}",
|
|
228
|
-
result,
|
|
229
|
-
flags=re.MULTILINE,
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
if version_field is not None:
|
|
233
|
-
result = re.sub(
|
|
234
|
-
r"^-\s*\*\*Version:\*\*\s+.*$",
|
|
235
|
-
f"- **Version:** {version_field}",
|
|
236
|
-
result,
|
|
237
|
-
flags=re.MULTILINE,
|
|
238
|
-
)
|
|
239
|
-
result = re.sub(
|
|
240
|
-
r"^-\s*Version\s*:\s+.*$",
|
|
241
|
-
f"- Version: {version_field}",
|
|
242
|
-
result,
|
|
243
|
-
flags=re.MULTILINE,
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
if not re.search(
|
|
247
|
-
r"^-\s*(?:\*\*Issue:\*\*\s*|Issue\s*:)\s*#?",
|
|
248
|
-
result,
|
|
249
|
-
flags=re.MULTILINE,
|
|
250
|
-
):
|
|
251
|
-
result = f"- Issue: {issue_field}\n{result}"
|
|
252
|
-
return result
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
"""Core models and basic utilities for active feature folder creation."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import re
|
|
6
|
-
import shutil
|
|
7
|
-
from dataclasses import dataclass
|
|
8
|
-
from datetime import datetime
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import TYPE_CHECKING, Protocol
|
|
11
|
-
from zoneinfo import ZoneInfo
|
|
12
|
-
|
|
13
|
-
if TYPE_CHECKING:
|
|
14
|
-
from collections.abc import Callable, Iterable
|
|
15
|
-
|
|
16
|
-
NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:[-_][a-z0-9]+)*$")
|
|
17
|
-
EXCLUDED_POTENTIAL_NAMES = {"template.md", "README.md"}
|
|
18
|
-
PLACEHOLDERS = [
|
|
19
|
-
"<feature-name>",
|
|
20
|
-
"<refactor-name>",
|
|
21
|
-
"<epic-name>",
|
|
22
|
-
"<name>",
|
|
23
|
-
"<bug-name>",
|
|
24
|
-
]
|
|
25
|
-
|
|
26
|
-
PLAN_TIMESTAMP_TEMPLATE_NAME = "plan.yyyy-MM-ddTHH-mm.md"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@dataclass
|
|
30
|
-
class IssueMeta:
|
|
31
|
-
"""Issue metadata container."""
|
|
32
|
-
|
|
33
|
-
number: str
|
|
34
|
-
author: str
|
|
35
|
-
updated_date: str
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@dataclass
|
|
39
|
-
class ActiveFolderResult:
|
|
40
|
-
"""Result payload for active-folder creation."""
|
|
41
|
-
|
|
42
|
-
target: Path
|
|
43
|
-
potential_issue_path: Path | None
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class FileSystem(Protocol):
|
|
47
|
-
"""Filesystem contract for folder creation workflows."""
|
|
48
|
-
|
|
49
|
-
def exists(self, path: Path) -> bool: ...
|
|
50
|
-
|
|
51
|
-
def ensure_dir(self, path: Path) -> None: ...
|
|
52
|
-
|
|
53
|
-
def copy_file(self, src: Path, dest: Path) -> None: ...
|
|
54
|
-
|
|
55
|
-
def copy_tree(self, src: Path, dest: Path) -> None: ...
|
|
56
|
-
|
|
57
|
-
def list_files(self, path: Path) -> Iterable[Path]: ...
|
|
58
|
-
|
|
59
|
-
def read_text(self, path: Path) -> str: ...
|
|
60
|
-
|
|
61
|
-
def write_text(self, path: Path, content: str) -> None: ...
|
|
62
|
-
|
|
63
|
-
def move(self, src: Path, dest: Path) -> None: ...
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@dataclass
|
|
67
|
-
class RealFileSystem(FileSystem):
|
|
68
|
-
"""Disk-backed FileSystem implementation."""
|
|
69
|
-
|
|
70
|
-
def exists(self, path: Path) -> bool:
|
|
71
|
-
return path.exists()
|
|
72
|
-
|
|
73
|
-
def ensure_dir(self, path: Path) -> None:
|
|
74
|
-
path.mkdir(parents=True, exist_ok=True)
|
|
75
|
-
|
|
76
|
-
def copy_file(self, src: Path, dest: Path) -> None:
|
|
77
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
-
shutil.copyfile(src, dest)
|
|
79
|
-
|
|
80
|
-
def copy_tree(self, src: Path, dest: Path) -> None:
|
|
81
|
-
for file_path in src.rglob("*"):
|
|
82
|
-
if file_path.is_dir():
|
|
83
|
-
continue
|
|
84
|
-
relative = file_path.relative_to(src)
|
|
85
|
-
target_path = dest / relative
|
|
86
|
-
self.copy_file(file_path, target_path)
|
|
87
|
-
|
|
88
|
-
def list_files(self, path: Path) -> Iterable[Path]:
|
|
89
|
-
if not path.exists():
|
|
90
|
-
return []
|
|
91
|
-
return [p for p in path.iterdir() if p.is_file()]
|
|
92
|
-
|
|
93
|
-
def read_text(self, path: Path) -> str:
|
|
94
|
-
return path.read_text(encoding="utf-8")
|
|
95
|
-
|
|
96
|
-
def write_text(self, path: Path, content: str) -> None:
|
|
97
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
-
path.write_text(content, encoding="utf-8")
|
|
99
|
-
|
|
100
|
-
def move(self, src: Path, dest: Path) -> None:
|
|
101
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
-
if dest.exists() and dest.is_file():
|
|
103
|
-
dest.unlink()
|
|
104
|
-
src.replace(dest)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def resolve_workspace() -> Path:
|
|
108
|
-
"""Resolve repository workspace root."""
|
|
109
|
-
return Path.cwd()
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def get_est_timestamp(now_provider: Callable[[], datetime] | None = None) -> str:
|
|
113
|
-
"""Return a Windows-safe timestamp string for America/New_York."""
|
|
114
|
-
now = (
|
|
115
|
-
now_provider()
|
|
116
|
-
if now_provider
|
|
117
|
-
else datetime.now(tz=ZoneInfo("America/New_York"))
|
|
118
|
-
)
|
|
119
|
-
if now.tzinfo is None:
|
|
120
|
-
raise ValueError("now_provider must return a timezone-aware datetime")
|
|
121
|
-
localized = now.astimezone(ZoneInfo("America/New_York"))
|
|
122
|
-
return localized.strftime("%Y-%m-%dT%H-%M")
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def extract_date_from_timestamp(timestamp: str) -> str:
|
|
126
|
-
"""Extract YYYY-MM-DD date component from a timestamp string."""
|
|
127
|
-
return timestamp.split("T", 1)[0]
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def validate_feature_name(feature_name: str) -> None:
|
|
131
|
-
"""Validate feature-name slug format."""
|
|
132
|
-
if not feature_name or not NAME_PATTERN.fullmatch(feature_name):
|
|
133
|
-
raise ValueError(
|
|
134
|
-
f"Aborted: '{feature_name}' is invalid. Use kebab/underscore-case "
|
|
135
|
-
"letters/numbers (e.g., notes-feature or notes_feature)."
|
|
136
|
-
)
|