55import re
66from 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+" )
1010PLACEHOLDER_RE = re .compile (r"^(?:TBD|\[To be written\]|\[[^\]]+\])$" , re .IGNORECASE )
1111
1212FULL_MODE_REQUIRED_SECTIONS = (
3232def 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+
126171def _is_placeholder_content (content : str ) -> bool :
127172 stripped_content = content .strip ()
128173 if not stripped_content :
0 commit comments