elspais 0.11.1__py3-none-any.whl → 0.11.2__py3-none-any.whl

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.
Files changed (53) hide show
  1. elspais/__init__.py +1 -1
  2. elspais/cli.py +29 -10
  3. elspais/commands/analyze.py +5 -6
  4. elspais/commands/changed.py +2 -6
  5. elspais/commands/config_cmd.py +4 -4
  6. elspais/commands/edit.py +32 -36
  7. elspais/commands/hash_cmd.py +24 -18
  8. elspais/commands/index.py +8 -7
  9. elspais/commands/init.py +4 -4
  10. elspais/commands/reformat_cmd.py +32 -43
  11. elspais/commands/rules_cmd.py +6 -2
  12. elspais/commands/trace.py +23 -19
  13. elspais/commands/validate.py +8 -10
  14. elspais/config/defaults.py +7 -1
  15. elspais/core/content_rules.py +0 -1
  16. elspais/core/git.py +4 -10
  17. elspais/core/parser.py +55 -56
  18. elspais/core/patterns.py +2 -6
  19. elspais/core/rules.py +10 -15
  20. elspais/mcp/__init__.py +2 -0
  21. elspais/mcp/context.py +1 -0
  22. elspais/mcp/serializers.py +1 -1
  23. elspais/mcp/server.py +54 -39
  24. elspais/reformat/__init__.py +13 -13
  25. elspais/reformat/detector.py +9 -16
  26. elspais/reformat/hierarchy.py +8 -7
  27. elspais/reformat/line_breaks.py +36 -38
  28. elspais/reformat/prompts.py +22 -12
  29. elspais/reformat/transformer.py +43 -41
  30. elspais/sponsors/__init__.py +0 -2
  31. elspais/testing/__init__.py +1 -1
  32. elspais/testing/result_parser.py +25 -21
  33. elspais/trace_view/__init__.py +4 -3
  34. elspais/trace_view/coverage.py +5 -5
  35. elspais/trace_view/generators/__init__.py +1 -1
  36. elspais/trace_view/generators/base.py +17 -12
  37. elspais/trace_view/generators/csv.py +2 -6
  38. elspais/trace_view/generators/markdown.py +3 -8
  39. elspais/trace_view/html/__init__.py +4 -2
  40. elspais/trace_view/html/generator.py +423 -289
  41. elspais/trace_view/models.py +25 -0
  42. elspais/trace_view/review/__init__.py +21 -18
  43. elspais/trace_view/review/branches.py +114 -121
  44. elspais/trace_view/review/models.py +232 -237
  45. elspais/trace_view/review/position.py +53 -71
  46. elspais/trace_view/review/server.py +264 -288
  47. elspais/trace_view/review/status.py +43 -58
  48. elspais/trace_view/review/storage.py +48 -72
  49. {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/METADATA +1 -1
  50. {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/RECORD +53 -53
  51. {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/WHEEL +0 -0
  52. {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/entry_points.txt +0 -0
  53. {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/licenses/LICENSE +0 -0
elspais/core/patterns.py CHANGED
@@ -116,9 +116,7 @@ class PatternValidator:
116
116
  self.config = config
117
117
  self._regex = self._build_regex()
118
118
  self._regex_with_assertion = self._build_regex(include_assertion=True)
119
- self._assertion_label_regex = re.compile(
120
- f"^{self.config.get_assertion_label_pattern()}$"
121
- )
119
+ self._assertion_label_regex = re.compile(f"^{self.config.get_assertion_label_pattern()}$")
122
120
 
123
121
  def _build_regex(self, include_assertion: bool = False) -> re.Pattern:
124
122
  """Build regex pattern from configuration.
@@ -306,9 +304,7 @@ class PatternValidator:
306
304
 
307
305
  raise ValueError(f"Cannot parse assertion label: {label}")
308
306
 
309
- def format(
310
- self, type_code: str, number: int, associated: Optional[str] = None
311
- ) -> str:
307
+ def format(self, type_code: str, number: int, associated: Optional[str] = None) -> str:
312
308
  """
313
309
  Format a requirement ID from components.
314
310
 
elspais/core/rules.py CHANGED
@@ -101,9 +101,9 @@ class FormatConfig:
101
101
  require_shall: bool = True
102
102
  labels_sequential: bool = True
103
103
  labels_unique: bool = True
104
- placeholder_values: List[str] = field(default_factory=lambda: [
105
- "obsolete", "removed", "deprecated", "N/A", "n/a", "-", "reserved"
106
- ])
104
+ placeholder_values: List[str] = field(
105
+ default_factory=lambda: ["obsolete", "removed", "deprecated", "N/A", "n/a", "-", "reserved"]
106
+ )
107
107
 
108
108
 
109
109
  @dataclass
@@ -142,9 +142,10 @@ class RulesConfig:
142
142
  require_shall=format_data.get("require_shall", True),
143
143
  labels_sequential=format_data.get("labels_sequential", True),
144
144
  labels_unique=format_data.get("labels_unique", True),
145
- placeholder_values=format_data.get("placeholder_values", [
146
- "obsolete", "removed", "deprecated", "N/A", "n/a", "-", "reserved"
147
- ]),
145
+ placeholder_values=format_data.get(
146
+ "placeholder_values",
147
+ ["obsolete", "removed", "deprecated", "N/A", "n/a", "-", "reserved"],
148
+ ),
148
149
  )
149
150
 
150
151
  return cls(hierarchy=hierarchy, format=format_config)
@@ -169,9 +170,7 @@ class RuleEngine:
169
170
  """
170
171
  self.config = config
171
172
  self.pattern_config = pattern_config
172
- self.pattern_validator = (
173
- PatternValidator(pattern_config) if pattern_config else None
174
- )
173
+ self.pattern_validator = PatternValidator(pattern_config) if pattern_config else None
175
174
 
176
175
  def validate(self, requirements: Dict[str, Requirement]) -> List[RuleViolation]:
177
176
  """
@@ -381,9 +380,7 @@ class RuleEngine:
381
380
 
382
381
  return violations
383
382
 
384
- def _check_assertions(
385
- self, req_id: str, req: Requirement
386
- ) -> List[RuleViolation]:
383
+ def _check_assertions(self, req_id: str, req: Requirement) -> List[RuleViolation]:
387
384
  """Check assertion-specific validation rules."""
388
385
  violations = []
389
386
 
@@ -426,9 +423,7 @@ class RuleEngine:
426
423
  if self.config.format.labels_sequential and self.pattern_validator:
427
424
  expected_labels = []
428
425
  for i in range(len(labels)):
429
- expected_labels.append(
430
- self.pattern_validator.format_assertion_label(i)
431
- )
426
+ expected_labels.append(self.pattern_validator.format_assertion_label(i))
432
427
  if labels != expected_labels:
433
428
  msg = f"Labels not sequential: {labels} (expected {expected_labels})"
434
429
  violations.append(
elspais/mcp/__init__.py CHANGED
@@ -33,10 +33,12 @@ __all__ = [
33
33
  def create_server(working_dir=None):
34
34
  """Create MCP server instance."""
35
35
  from elspais.mcp.server import create_server as _create
36
+
36
37
  return _create(working_dir)
37
38
 
38
39
 
39
40
  def run_server(working_dir=None, transport="stdio"):
40
41
  """Run MCP server."""
41
42
  from elspais.mcp.server import run_server as _run
43
+
42
44
  return _run(working_dir, transport)
elspais/mcp/context.py CHANGED
@@ -52,6 +52,7 @@ class WorkspaceContext:
52
52
  else:
53
53
  # Use defaults
54
54
  from elspais.config.defaults import DEFAULT_CONFIG
55
+
55
56
  config = DEFAULT_CONFIG.copy()
56
57
 
57
58
  return cls(working_dir=directory, config=config)
@@ -4,7 +4,7 @@ elspais.mcp.serializers - JSON serialization for MCP responses.
4
4
  Provides functions to serialize elspais data models to JSON-compatible dicts.
5
5
  """
6
6
 
7
- from typing import Any, Dict, List
7
+ from typing import Any, Dict
8
8
 
9
9
  from elspais.core.models import Assertion, ContentRule, Requirement
10
10
  from elspais.core.rules import RuleViolation
elspais/mcp/server.py CHANGED
@@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional
9
9
 
10
10
  try:
11
11
  from mcp.server.fastmcp import FastMCP
12
+
12
13
  MCP_AVAILABLE = True
13
14
  except ImportError:
14
15
  MCP_AVAILABLE = False
@@ -39,8 +40,7 @@ def create_server(working_dir: Optional[Path] = None) -> "FastMCP":
39
40
  """
40
41
  if not MCP_AVAILABLE:
41
42
  raise ImportError(
42
- "MCP dependencies not installed. "
43
- "Install with: pip install elspais[mcp]"
43
+ "MCP dependencies not installed. " "Install with: pip install elspais[mcp]"
44
44
  )
45
45
 
46
46
  if working_dir is None:
@@ -75,14 +75,17 @@ def _register_resources(mcp: "FastMCP", ctx: WorkspaceContext) -> None:
75
75
  ID, title, level, status, and assertion count.
76
76
  """
77
77
  import json
78
+
78
79
  requirements = ctx.get_requirements()
79
- return json.dumps({
80
- "count": len(requirements),
81
- "requirements": [
82
- serialize_requirement_summary(req)
83
- for req in requirements.values()
84
- ]
85
- }, indent=2)
80
+ return json.dumps(
81
+ {
82
+ "count": len(requirements),
83
+ "requirements": [
84
+ serialize_requirement_summary(req) for req in requirements.values()
85
+ ],
86
+ },
87
+ indent=2,
88
+ )
86
89
 
87
90
  @mcp.resource("requirements://{req_id}")
88
91
  def get_requirement_resource(req_id: str) -> str:
@@ -93,6 +96,7 @@ def _register_resources(mcp: "FastMCP", ctx: WorkspaceContext) -> None:
93
96
  implements references, and location.
94
97
  """
95
98
  import json
99
+
96
100
  req = ctx.get_requirement(req_id)
97
101
  if req is None:
98
102
  return json.dumps({"error": f"Requirement {req_id} not found"})
@@ -102,34 +106,39 @@ def _register_resources(mcp: "FastMCP", ctx: WorkspaceContext) -> None:
102
106
  def get_requirements_by_level(level: str) -> str:
103
107
  """Get all requirements of a specific level (PRD, OPS, DEV)."""
104
108
  import json
109
+
105
110
  requirements = ctx.get_requirements()
106
- filtered = [
107
- r for r in requirements.values()
108
- if r.level.upper() == level.upper()
109
- ]
110
- return json.dumps({
111
- "level": level,
112
- "count": len(filtered),
113
- "requirements": [serialize_requirement_summary(r) for r in filtered]
114
- }, indent=2)
111
+ filtered = [r for r in requirements.values() if r.level.upper() == level.upper()]
112
+ return json.dumps(
113
+ {
114
+ "level": level,
115
+ "count": len(filtered),
116
+ "requirements": [serialize_requirement_summary(r) for r in filtered],
117
+ },
118
+ indent=2,
119
+ )
115
120
 
116
121
  @mcp.resource("content-rules://list")
117
122
  def list_content_rules() -> str:
118
123
  """List all configured content rule files."""
119
124
  import json
125
+
120
126
  rules = ctx.get_content_rules()
121
- return json.dumps({
122
- "count": len(rules),
123
- "rules": [
124
- {
125
- "file": str(r.file_path),
126
- "title": r.title,
127
- "type": r.type,
128
- "applies_to": r.applies_to,
129
- }
130
- for r in rules
131
- ]
132
- }, indent=2)
127
+ return json.dumps(
128
+ {
129
+ "count": len(rules),
130
+ "rules": [
131
+ {
132
+ "file": str(r.file_path),
133
+ "title": r.title,
134
+ "type": r.type,
135
+ "applies_to": r.applies_to,
136
+ }
137
+ for r in rules
138
+ ],
139
+ },
140
+ indent=2,
141
+ )
133
142
 
134
143
  @mcp.resource("content-rules://{filename}")
135
144
  def get_content_rule(filename: str) -> str:
@@ -140,6 +149,7 @@ def _register_resources(mcp: "FastMCP", ctx: WorkspaceContext) -> None:
140
149
  requirement formats and authoring guidelines.
141
150
  """
142
151
  import json
152
+
143
153
  rules = ctx.get_content_rules()
144
154
  for rule in rules:
145
155
  if rule.file_path.name == filename or str(rule.file_path).endswith(filename):
@@ -150,6 +160,7 @@ def _register_resources(mcp: "FastMCP", ctx: WorkspaceContext) -> None:
150
160
  def get_current_config() -> str:
151
161
  """Get the current elspais configuration."""
152
162
  import json
163
+
153
164
  return json.dumps(ctx.config, indent=2, default=str)
154
165
 
155
166
 
@@ -186,7 +197,10 @@ def _register_tools(mcp: "FastMCP", ctx: WorkspaceContext) -> None:
186
197
  "valid": len(errors) == 0,
187
198
  "errors": [serialize_violation(v) for v in errors],
188
199
  "warnings": [serialize_violation(v) for v in warnings],
189
- "summary": f"{len(errors)} errors, {len(warnings)} warnings in {len(requirements)} requirements"
200
+ "summary": (
201
+ f"{len(errors)} errors, {len(warnings)} warnings "
202
+ f"in {len(requirements)} requirements"
203
+ ),
190
204
  }
191
205
 
192
206
  @mcp.tool()
@@ -209,9 +223,8 @@ def _register_tools(mcp: "FastMCP", ctx: WorkspaceContext) -> None:
209
223
  return {
210
224
  "count": len(requirements),
211
225
  "requirements": {
212
- req_id: serialize_requirement(req)
213
- for req_id, req in requirements.items()
214
- }
226
+ req_id: serialize_requirement(req) for req_id, req in requirements.items()
227
+ },
215
228
  }
216
229
 
217
230
  @mcp.tool()
@@ -233,7 +246,7 @@ def _register_tools(mcp: "FastMCP", ctx: WorkspaceContext) -> None:
233
246
  "count": len(results),
234
247
  "query": query,
235
248
  "field": field,
236
- "requirements": [serialize_requirement_summary(r) for r in results]
249
+ "requirements": [serialize_requirement_summary(r) for r in results],
237
250
  }
238
251
 
239
252
  @mcp.tool()
@@ -299,10 +312,12 @@ def _analyze_orphans(requirements: Dict[str, Any]) -> Dict[str, Any]:
299
312
  for req in requirements.values():
300
313
  for parent_id in req.implements:
301
314
  if parent_id not in all_ids:
302
- orphans.append({
303
- "id": req.id,
304
- "missing_parent": parent_id,
305
- })
315
+ orphans.append(
316
+ {
317
+ "id": req.id,
318
+ "missing_parent": parent_id,
319
+ }
320
+ )
306
321
 
307
322
  return {
308
323
  "count": len(orphans),
@@ -9,23 +9,23 @@ IMPLEMENTS REQUIREMENTS:
9
9
  REQ-int-d00008: Reformat Command
10
10
  """
11
11
 
12
- from elspais.reformat.detector import detect_format, needs_reformatting, FormatAnalysis
13
- from elspais.reformat.transformer import (
14
- reformat_requirement,
15
- assemble_new_format,
16
- validate_reformatted_content,
17
- )
18
- from elspais.reformat.line_breaks import (
19
- normalize_line_breaks,
20
- fix_requirement_line_breaks,
21
- detect_line_break_issues,
22
- )
12
+ from elspais.reformat.detector import FormatAnalysis, detect_format, needs_reformatting
23
13
  from elspais.reformat.hierarchy import (
24
14
  RequirementNode,
25
- get_all_requirements,
26
15
  build_hierarchy,
27
- traverse_top_down,
16
+ get_all_requirements,
28
17
  normalize_req_id,
18
+ traverse_top_down,
19
+ )
20
+ from elspais.reformat.line_breaks import (
21
+ detect_line_break_issues,
22
+ fix_requirement_line_breaks,
23
+ normalize_line_breaks,
24
+ )
25
+ from elspais.reformat.transformer import (
26
+ assemble_new_format,
27
+ reformat_requirement,
28
+ validate_reformatted_content,
29
29
  )
30
30
 
31
31
  __all__ = [
@@ -13,6 +13,7 @@ from dataclasses import dataclass
13
13
  @dataclass
14
14
  class FormatAnalysis:
15
15
  """Result of format detection analysis."""
16
+
16
17
  is_new_format: bool
17
18
  has_assertions_section: bool
18
19
  has_labeled_assertions: bool
@@ -46,36 +47,28 @@ def detect_format(body: str, rationale: str = "") -> FormatAnalysis:
46
47
  full_text = f"{body}\n{rationale}".strip()
47
48
 
48
49
  # Check for ## Assertions section
49
- has_assertions_section = bool(
50
- re.search(r'^##\s+Assertions\s*$', full_text, re.MULTILINE)
51
- )
50
+ has_assertions_section = bool(re.search(r"^##\s+Assertions\s*$", full_text, re.MULTILINE))
52
51
 
53
52
  # Check for labeled assertions (A., B., C., etc. followed by SHALL somewhere in the line)
54
53
  labeled_assertions = re.findall(
55
- r'^[A-Z]\.\s+.*\bSHALL\b',
56
- full_text,
57
- re.MULTILINE | re.IGNORECASE
54
+ r"^[A-Z]\.\s+.*\bSHALL\b", full_text, re.MULTILINE | re.IGNORECASE
58
55
  )
59
56
  has_labeled_assertions = len(labeled_assertions) >= 1
60
57
  assertion_count = len(labeled_assertions)
61
58
 
62
59
  # Check for Acceptance Criteria section
63
- has_acceptance_criteria = bool(re.search(
64
- r'\*?\*?Acceptance\s+Criteria\*?\*?\s*:',
65
- full_text,
66
- re.IGNORECASE
67
- ))
60
+ has_acceptance_criteria = bool(
61
+ re.search(r"\*?\*?Acceptance\s+Criteria\*?\*?\s*:", full_text, re.IGNORECASE)
62
+ )
68
63
 
69
64
  # Check for SHALL language usage anywhere
70
- shall_count = len(re.findall(r'\bSHALL\b', full_text, re.IGNORECASE))
65
+ shall_count = len(re.findall(r"\bSHALL\b", full_text, re.IGNORECASE))
71
66
  uses_shall_language = shall_count >= 1
72
67
 
73
68
  # Determine if new format
74
69
  # New format: has Assertions section with labeled assertions, no Acceptance Criteria
75
70
  is_new_format = (
76
- has_assertions_section and
77
- has_labeled_assertions and
78
- not has_acceptance_criteria
71
+ has_assertions_section and has_labeled_assertions and not has_acceptance_criteria
79
72
  )
80
73
 
81
74
  # Calculate confidence score
@@ -100,7 +93,7 @@ def detect_format(body: str, rationale: str = "") -> FormatAnalysis:
100
93
  has_acceptance_criteria=has_acceptance_criteria,
101
94
  uses_shall_language=uses_shall_language,
102
95
  assertion_count=assertion_count,
103
- confidence=confidence
96
+ confidence=confidence,
104
97
  )
105
98
 
106
99
 
@@ -9,7 +9,7 @@ a traversable hierarchy based on implements relationships.
9
9
  import sys
10
10
  from dataclasses import dataclass, field
11
11
  from pathlib import Path
12
- from typing import Callable, Dict, List, Optional, TYPE_CHECKING
12
+ from typing import TYPE_CHECKING, Callable, Dict, List, Optional
13
13
 
14
14
  if TYPE_CHECKING:
15
15
  from elspais.core.models import Requirement
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
19
19
  @dataclass
20
20
  class RequirementNode:
21
21
  """Represents a requirement with its metadata and hierarchy info."""
22
+
22
23
  req_id: str
23
24
  title: str
24
25
  body: str
@@ -76,10 +77,10 @@ def get_all_requirements(
76
77
  Returns:
77
78
  Dict mapping requirement ID (e.g., 'REQ-d00027') to RequirementNode
78
79
  """
79
- from elspais.config.loader import load_config, find_config_file, get_spec_directories
80
+ from elspais.commands.validate import load_requirements_from_repo
81
+ from elspais.config.loader import find_config_file, get_spec_directories, load_config
80
82
  from elspais.core.parser import RequirementParser
81
83
  from elspais.core.patterns import PatternConfig
82
- from elspais.commands.validate import load_requirements_from_repo
83
84
 
84
85
  # Find and load config
85
86
  if config_path is None:
@@ -140,7 +141,7 @@ def build_hierarchy(requirements: Dict[str, RequirementNode]) -> Dict[str, Requi
140
141
  for req_id, node in requirements.items():
141
142
  for parent_id in node.implements:
142
143
  # Normalize parent ID format
143
- parent_key = parent_id if parent_id.startswith('REQ-') else f"REQ-{parent_id}"
144
+ parent_key = parent_id if parent_id.startswith("REQ-") else f"REQ-{parent_id}"
144
145
  if parent_key in requirements:
145
146
  requirements[parent_key].children.append(req_id)
146
147
 
@@ -155,7 +156,7 @@ def traverse_top_down(
155
156
  requirements: Dict[str, RequirementNode],
156
157
  start_req: str,
157
158
  max_depth: Optional[int] = None,
158
- callback: Optional[Callable[[RequirementNode, int], None]] = None
159
+ callback: Optional[Callable[[RequirementNode, int], None]] = None,
159
160
  ) -> List[str]:
160
161
  """
161
162
  Traverse hierarchy from start_req downward using BFS.
@@ -214,8 +215,8 @@ def normalize_req_id(req_id: str, validator: Optional["PatternValidator"] = None
214
215
  Returns:
215
216
  Normalized ID in canonical format from config
216
217
  """
217
- from elspais.config.loader import load_config, find_config_file
218
- from elspais.core.patterns import PatternValidator, PatternConfig
218
+ from elspais.config.loader import find_config_file, load_config
219
+ from elspais.core.patterns import PatternConfig, PatternValidator
219
220
 
220
221
  # Create validator if not provided
221
222
  if validator is None:
@@ -26,7 +26,7 @@ def normalize_line_breaks(content: str, reflow: bool = True) -> str:
26
26
  Returns:
27
27
  Content with normalized line breaks
28
28
  """
29
- lines = content.split('\n')
29
+ lines = content.split("\n")
30
30
  result_lines: List[str] = []
31
31
 
32
32
  i = 0
@@ -34,14 +34,14 @@ def normalize_line_breaks(content: str, reflow: bool = True) -> str:
34
34
  line = lines[i]
35
35
 
36
36
  # Check if this is a section header (## Something)
37
- if re.match(r'^##\s+\w', line):
37
+ if re.match(r"^##\s+\w", line):
38
38
  result_lines.append(line)
39
39
  # Skip blank lines immediately after section header
40
40
  i += 1
41
- while i < len(lines) and lines[i].strip() == '':
41
+ while i < len(lines) and lines[i].strip() == "":
42
42
  i += 1
43
43
  # Add single blank line after header for readability
44
- result_lines.append('')
44
+ result_lines.append("")
45
45
  continue
46
46
 
47
47
  # Check if this starts a paragraph that might need reflowing
@@ -52,9 +52,11 @@ def normalize_line_breaks(content: str, reflow: bool = True) -> str:
52
52
  while i < len(lines):
53
53
  next_line = lines[i]
54
54
  # Stop at blank lines, structural elements, or next section
55
- if (next_line.strip() == '' or
56
- _is_structural_line(next_line) or
57
- re.match(r'^##\s+', next_line)):
55
+ if (
56
+ next_line.strip() == ""
57
+ or _is_structural_line(next_line)
58
+ or re.match(r"^##\s+", next_line)
59
+ ):
58
60
  break
59
61
  para_lines.append(next_line.rstrip())
60
62
  i += 1
@@ -69,7 +71,7 @@ def normalize_line_breaks(content: str, reflow: bool = True) -> str:
69
71
  i += 1
70
72
 
71
73
  # Clean up multiple consecutive blank lines
72
- return _collapse_blank_lines('\n'.join(result_lines))
74
+ return _collapse_blank_lines("\n".join(result_lines))
73
75
 
74
76
 
75
77
  def _is_structural_line(line: str) -> bool:
@@ -89,35 +91,35 @@ def _is_structural_line(line: str) -> bool:
89
91
  return False
90
92
 
91
93
  # Headers
92
- if stripped.startswith('#'):
94
+ if stripped.startswith("#"):
93
95
  return True
94
96
 
95
97
  # Lettered assertions (A. B. C. etc)
96
- if re.match(r'^[A-Z]\.\s', stripped):
98
+ if re.match(r"^[A-Z]\.\s", stripped):
97
99
  return True
98
100
 
99
101
  # Numbered lists (1. 2. 3. etc)
100
- if re.match(r'^\d+\.\s', stripped):
102
+ if re.match(r"^\d+\.\s", stripped):
101
103
  return True
102
104
 
103
105
  # Bullet points
104
- if stripped.startswith(('- ', '* ', '+ ')):
106
+ if stripped.startswith(("- ", "* ", "+ ")):
105
107
  return True
106
108
 
107
109
  # Metadata line
108
- if stripped.startswith('**Level**:') or stripped.startswith('**Status**:'):
110
+ if stripped.startswith("**Level**:") or stripped.startswith("**Status**:"):
109
111
  return True
110
112
 
111
113
  # Combined metadata line
112
- if re.match(r'\*\*Level\*\*:', stripped):
114
+ if re.match(r"\*\*Level\*\*:", stripped):
113
115
  return True
114
116
 
115
117
  # End marker
116
- if stripped.startswith('*End*'):
118
+ if stripped.startswith("*End*"):
117
119
  return True
118
120
 
119
121
  # Code fence
120
- if stripped.startswith('```'):
122
+ if stripped.startswith("```"):
121
123
  return True
122
124
 
123
125
  return False
@@ -134,15 +136,15 @@ def _reflow_paragraph(lines: List[str]) -> str:
134
136
  Single reflowed line
135
137
  """
136
138
  if not lines:
137
- return ''
139
+ return ""
138
140
 
139
141
  if len(lines) == 1:
140
142
  return lines[0]
141
143
 
142
144
  # Join lines with space, collapsing multiple spaces
143
- joined = ' '.join(line.strip() for line in lines if line.strip())
145
+ joined = " ".join(line.strip() for line in lines if line.strip())
144
146
  # Collapse multiple spaces
145
- return re.sub(r'\s+', ' ', joined)
147
+ return re.sub(r"\s+", " ", joined)
146
148
 
147
149
 
148
150
  def _collapse_blank_lines(content: str) -> str:
@@ -156,14 +158,10 @@ def _collapse_blank_lines(content: str) -> str:
156
158
  Content with at most one blank line between paragraphs
157
159
  """
158
160
  # Replace 3+ newlines with 2 newlines (one blank line)
159
- return re.sub(r'\n{3,}', '\n\n', content)
161
+ return re.sub(r"\n{3,}", "\n\n", content)
160
162
 
161
163
 
162
- def fix_requirement_line_breaks(
163
- body: str,
164
- rationale: str,
165
- reflow: bool = True
166
- ) -> Tuple[str, str]:
164
+ def fix_requirement_line_breaks(body: str, rationale: str, reflow: bool = True) -> Tuple[str, str]:
167
165
  """
168
166
  Fix line breaks in requirement body and rationale.
169
167
 
@@ -175,8 +173,8 @@ def fix_requirement_line_breaks(
175
173
  Returns:
176
174
  Tuple of (fixed_body, fixed_rationale)
177
175
  """
178
- fixed_body = normalize_line_breaks(body, reflow=reflow) if body else ''
179
- fixed_rationale = normalize_line_breaks(rationale, reflow=reflow) if rationale else ''
176
+ fixed_body = normalize_line_breaks(body, reflow=reflow) if body else ""
177
+ fixed_rationale = normalize_line_breaks(rationale, reflow=reflow) if rationale else ""
180
178
 
181
179
  return fixed_body, fixed_rationale
182
180
 
@@ -188,15 +186,15 @@ def detect_line_break_issues(content: str) -> List[str]:
188
186
  Returns list of issues found for reporting.
189
187
  """
190
188
  issues = []
191
- lines = content.split('\n')
189
+ lines = content.split("\n")
192
190
 
193
191
  for i, line in enumerate(lines):
194
192
  # Check for blank line after section header
195
- if re.match(r'^##\s+\w', line):
193
+ if re.match(r"^##\s+\w", line):
196
194
  # Look ahead for multiple blank lines
197
195
  blank_count = 0
198
196
  j = i + 1
199
- while j < len(lines) and lines[j].strip() == '':
197
+ while j < len(lines) and lines[j].strip() == "":
200
198
  blank_count += 1
201
199
  j += 1
202
200
  if blank_count > 1:
@@ -206,15 +204,15 @@ def detect_line_break_issues(content: str) -> List[str]:
206
204
 
207
205
  # Check for mid-sentence line break (line ends without punctuation)
208
206
  stripped = line.rstrip()
209
- if (stripped and
210
- not _is_structural_line(line) and
211
- i + 1 < len(lines) and
212
- lines[i + 1].strip() and
213
- not _is_structural_line(lines[i + 1])):
207
+ if (
208
+ stripped
209
+ and not _is_structural_line(line)
210
+ and i + 1 < len(lines)
211
+ and lines[i + 1].strip()
212
+ and not _is_structural_line(lines[i + 1])
213
+ ):
214
214
  # Line ends with a word (not punctuation), followed by non-empty line
215
215
  if stripped and stripped[-1].isalnum():
216
- issues.append(
217
- f"Line {i+1}: Possible mid-sentence line break"
218
- )
216
+ issues.append(f"Line {i+1}: Possible mid-sentence line break")
219
217
 
220
218
  return issues