Skip to content

Commit 96b23d2

Browse files
committed
chore: update project version to 0.8.3
1 parent f4e2cdc commit 96b23d2

File tree

5 files changed

+117
-27
lines changed

5 files changed

+117
-27
lines changed

.rumdl.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ exclude = [
2525
"dist",
2626
"build",
2727
"target",
28+
"testdata",
2829

2930
# Specific files or patterns
3031
"CHANGELOG.md",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "uv_build"
44

55
[project]
66
name = "pb-spec"
7-
version = "0.8.2"
7+
version = "0.8.3"
88
description = "Plan-Build Spec (pb-spec): A CLI tool for managing AI coding assistant skills"
99
readme = "README.md"
1010
license = "Apache-2.0"

src/pb_spec/validation/design.py

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import re
66
from pathlib import Path
77

8-
HEADING_RE = re.compile(r"^#{2,6}\s+(.+?)\s*$")
9-
LEADING_NUMBER_RE = re.compile(r"^\d+(?:\.\d+)*\.\s+")
8+
HEADING_RE = re.compile(r"^(#{2,6})\s+(.+?)\s*$")
9+
LEADING_NUMBER_RE = re.compile(r"^\d+(?:\.\d+)*\.?\s+")
1010
PLACEHOLDER_RE = re.compile(r"^(?:TBD|\[To be written\]|\[[^\]]+\])$", re.IGNORECASE)
1111

1212
FULL_MODE_REQUIRED_SECTIONS = (
@@ -32,57 +32,67 @@
3232
def validate_design_file(design_file: Path) -> list[str]:
3333
"""Validate required design.md sections for full or lightweight mode."""
3434
sections = _parse_design_sections(design_file)
35-
section_names = {name for name, _ in sections}
36-
required_sections = _select_required_sections(section_names)
35+
section_names = {name for name, _, _ in sections}
36+
required_sections = _select_required_sections(section_names, sections)
3737

3838
errors: list[str] = []
39-
section_map = dict(sections)
39+
section_map = {name: content for name, content, _ in sections}
4040
for section_name in required_sections:
4141
if section_name not in section_map:
4242
errors.append(f"Missing required design section in design.md: {section_name}")
4343
continue
4444

45-
if _is_placeholder_content(section_map[section_name]):
45+
if _is_placeholder_content(section_map[section_name]) and not _has_subsections(
46+
section_name, sections
47+
):
4648
errors.append(
4749
f"Required design section is empty or placeholder in design.md: {section_name}"
4850
)
4951

50-
for section_name in _select_conditional_required_sections(section_names):
52+
for section_name in _select_conditional_required_sections(section_names, sections):
5153
if section_name in required_sections:
5254
continue
5355
if section_name not in section_map:
5456
errors.append(f"Missing required design section in design.md: {section_name}")
5557
continue
5658

57-
if _is_placeholder_content(section_map[section_name]):
59+
if _is_placeholder_content(section_map[section_name]) and not _has_subsections(
60+
section_name, sections
61+
):
5862
errors.append(
5963
f"Required design section is empty or placeholder in design.md: {section_name}"
6064
)
6165

6266
return errors
6367

6468

65-
def _parse_design_sections(design_file: Path) -> list[tuple[str, str]]:
69+
def _parse_design_sections(design_file: Path) -> list[tuple[str, str, int]]:
70+
"""Parse design.md sections, returning (name, content, heading_level) tuples."""
6671
lines = design_file.read_text(encoding="utf-8").splitlines()
67-
sections: list[tuple[str, str]] = []
72+
sections: list[tuple[str, str, int]] = []
6873
current_heading: str | None = None
74+
current_level: int = 0
6975
current_content: list[str] = []
7076

7177
for line in lines:
7278
heading_match = HEADING_RE.match(line)
7379
if heading_match:
74-
normalized_heading = _normalize_heading(heading_match.group(1))
80+
level = len(heading_match.group(1))
81+
normalized_heading = _normalize_heading(heading_match.group(2))
7582
if current_heading is not None:
76-
sections.append((current_heading, "\n".join(current_content).strip()))
83+
sections.append(
84+
(current_heading, "\n".join(current_content).strip(), current_level)
85+
)
7786
current_heading = normalized_heading
87+
current_level = level
7888
current_content = []
7989
continue
8090

8191
if current_heading is not None:
8292
current_content.append(line)
8393

8494
if current_heading is not None:
85-
sections.append((current_heading, "\n".join(current_content).strip()))
95+
sections.append((current_heading, "\n".join(current_content).strip(), current_level))
8696

8797
return sections
8898

@@ -100,15 +110,32 @@ def _normalize_heading(heading: str) -> str:
100110
)
101111

102112

103-
def _select_required_sections(section_names: set[str]) -> tuple[str, ...]:
104-
if section_names & LIGHTWEIGHT_ONLY_SECTIONS:
113+
def _select_required_sections(
114+
section_names: set[str], sections: list[tuple[str, str, int]] | None = None
115+
) -> tuple[str, ...]:
116+
lightweight_names = section_names & LIGHTWEIGHT_ONLY_SECTIONS
117+
if lightweight_names and sections:
118+
for name, _, level in sections:
119+
if name in lightweight_names and level == 2:
120+
return LIGHTWEIGHT_MODE_REQUIRED_SECTIONS
121+
elif lightweight_names:
105122
return LIGHTWEIGHT_MODE_REQUIRED_SECTIONS
106123
return FULL_MODE_REQUIRED_SECTIONS
107124

108125

109-
def _select_conditional_required_sections(section_names: set[str]) -> tuple[str, ...]:
126+
def _select_conditional_required_sections(
127+
section_names: set[str], sections: list[tuple[str, str, int]] | None = None
128+
) -> tuple[str, ...]:
110129
conditional_sections: list[str] = []
111-
is_lightweight = bool(section_names & LIGHTWEIGHT_ONLY_SECTIONS)
130+
is_lightweight = False
131+
lightweight_names = section_names & LIGHTWEIGHT_ONLY_SECTIONS
132+
if lightweight_names and sections:
133+
for name, _, level in sections:
134+
if name in lightweight_names and level == 2:
135+
is_lightweight = True
136+
break
137+
elif lightweight_names:
138+
is_lightweight = True
112139

113140
if "Existing Components to Reuse" not in section_names:
114141
conditional_sections.append("Existing Components to Reuse")
@@ -123,6 +150,24 @@ def _select_conditional_required_sections(section_names: set[str]) -> tuple[str,
123150
return tuple(conditional_sections)
124151

125152

153+
def _has_subsections(section_name: str, sections: list[tuple[str, str, int]]) -> bool:
154+
"""Check whether a section has child subsections at a deeper heading level."""
155+
idx = None
156+
parent_level = 0
157+
for i, (name, _, level) in enumerate(sections):
158+
if name == section_name:
159+
idx = i
160+
parent_level = level
161+
break
162+
if idx is None:
163+
return False
164+
for _, _, level in sections[idx + 1 :]:
165+
if level > parent_level:
166+
return True
167+
break
168+
return False
169+
170+
126171
def _is_placeholder_content(content: str) -> bool:
127172
stripped_content = content.strip()
128173
if not stripped_content:

src/pb_spec/validation/tasks.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,13 @@ def validate_task_file(
168168
"Scenario Coverage must name a concrete scenario for "
169169
f"{task_block.task_id} when Loop Type is BDD+TDD"
170170
)
171-
elif scenario_inventory is not None and scenario_coverage not in scenario_inventory:
172-
errors.append(
173-
f"Scenario reference not found for {task_block.task_id}: {scenario_coverage}"
174-
)
171+
elif scenario_inventory is not None:
172+
scenario_names = _extract_scenario_names(scenario_coverage)
173+
for scenario_name in scenario_names:
174+
if scenario_name not in scenario_inventory:
175+
errors.append(
176+
f"Scenario reference not found for {task_block.task_id}: {scenario_name}"
177+
)
175178

176179
return errors
177180

@@ -182,7 +185,7 @@ def find_referenced_scenarios(task_blocks: list[TaskBlock]) -> set[str]:
182185
for block in task_blocks:
183186
coverage = block.fields.get("Scenario Coverage")
184187
if coverage is not None and not _is_not_applicable_value(coverage):
185-
referenced.add(coverage)
188+
referenced.update(_extract_scenario_names(coverage))
186189
return referenced
187190

188191

@@ -204,20 +207,20 @@ def _build_task_block(task_id: str, name: str, body_lines: list[str]) -> TaskBlo
204207
for line in body_lines:
205208
quote_field_match = QUOTE_FIELD_RE.match(line)
206209
if quote_field_match:
207-
fields[quote_field_match.group(1)] = quote_field_match.group(2)
210+
fields[quote_field_match.group(1)] = _strip_backticks(quote_field_match.group(2))
208211
continue
209212

210213
field_match = FIELD_RE.match(line)
211214
if field_match:
212-
fields[field_match.group(1)] = field_match.group(2)
215+
fields[field_match.group(1)] = _strip_backticks(field_match.group(2))
213216
continue
214217

215218
if CHECKBOX_RE.match(line):
216219
evidence_checkbox_lines.append(line)
217220
checkbox_label_match = CHECKBOX_LABEL_RE.match(line)
218221
if checkbox_label_match:
219222
checkbox_fields[_normalize_checkbox_field_name(checkbox_label_match.group(1))] = (
220-
checkbox_label_match.group(2)
223+
_strip_backticks(checkbox_label_match.group(2))
221224
)
222225
if line.startswith("- [ ] **Step") or line.startswith("- [x] **Step"):
223226
checkbox_lines.append(line)
@@ -242,6 +245,47 @@ def _is_bare_not_applicable_value(value: str) -> bool:
242245
return value.strip() == "N/A"
243246

244247

248+
def _extract_scenario_names(coverage: str) -> list[str]:
249+
"""Extract all scenario names from a coverage value.
250+
251+
Handles formats:
252+
- 'Scenario Name'
253+
- 'feature_file.feature / Scenario Name'
254+
- 'feature_file.feature / all scenarios' (returns empty list)
255+
- '`feature / s1`, `feature / s2`' (multiple backtick-wrapped refs)
256+
"""
257+
if "`, `" in coverage:
258+
parts = coverage.split("`, `")
259+
names = []
260+
for part in parts:
261+
name = _parse_single_scenario_ref(part.strip("`").strip())
262+
if name is not None:
263+
names.append(name)
264+
return names
265+
name = _parse_single_scenario_ref(coverage)
266+
return [name] if name is not None else []
267+
268+
269+
def _parse_single_scenario_ref(value: str) -> str | None:
270+
"""Parse a single 'feature / scenario' or plain scenario reference."""
271+
stripped = value.strip().strip("`")
272+
if " / " in stripped:
273+
_feature, scenario = stripped.split(" / ", 1)
274+
scenario = scenario.strip()
275+
if scenario.lower() == "all scenarios":
276+
return None
277+
return scenario
278+
return stripped if stripped else None
279+
280+
281+
def _strip_backticks(value: str) -> str:
282+
"""Strip surrounding single backticks from a field value."""
283+
stripped = value.strip()
284+
if len(stripped) >= 2 and stripped.startswith("`") and stripped.endswith("`"):
285+
return stripped[1:-1]
286+
return stripped
287+
288+
245289
def _normalize_checkbox_field_name(name: str) -> str:
246290
normalized_name = name.strip()
247291
if normalized_name.startswith("Runtime Verification"):

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)