elspais 0.11.0__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 +75 -23
  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.0.dist-info → elspais-0.11.2.dist-info}/METADATA +12 -9
  50. {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/RECORD +53 -53
  51. {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/WHEEL +0 -0
  52. {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/entry_points.txt +0 -0
  53. {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/licenses/LICENSE +0 -0
@@ -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
@@ -14,23 +14,31 @@ JSON_SCHEMA = {
14
14
  "properties": {
15
15
  "rationale": {
16
16
  "type": "string",
17
- "description": "Non-normative context explaining why this requirement exists. No SHALL/MUST language."
17
+ "description": (
18
+ "Non-normative context explaining why this requirement exists. "
19
+ "No SHALL/MUST language."
20
+ ),
18
21
  },
19
22
  "assertions": {
20
23
  "type": "array",
21
24
  "items": {"type": "string"},
22
- "description": "List of assertions, each starting with 'The system SHALL...' or similar prescriptive language."
23
- }
25
+ "description": (
26
+ "List of assertions, each starting with 'The system SHALL...' "
27
+ "or similar prescriptive language."
28
+ ),
29
+ },
24
30
  },
25
- "required": ["rationale", "assertions"]
31
+ "required": ["rationale", "assertions"],
26
32
  }
27
33
 
28
- JSON_SCHEMA_STR = json.dumps(JSON_SCHEMA, separators=(',', ':'))
34
+ JSON_SCHEMA_STR = json.dumps(JSON_SCHEMA, separators=(",", ":"))
29
35
 
30
36
  # System prompt for requirement reformatting
31
- REFORMAT_SYSTEM_PROMPT = """You are a requirements engineering expert specializing in FDA 21 CFR Part 11 compliant clinical trial systems.
37
+ REFORMAT_SYSTEM_PROMPT = """You are a requirements engineering expert specializing in \
38
+ FDA 21 CFR Part 11 compliant clinical trial systems.
32
39
 
33
- Your task is to reformat requirements from an old descriptive format to a new prescriptive assertion-based format.
40
+ Your task is to reformat requirements from an old descriptive format to a new \
41
+ prescriptive assertion-based format.
34
42
 
35
43
  EXTRACTION RULES:
36
44
  1. Extract ALL obligations from the old format (body text, bullet points, acceptance criteria)
@@ -47,7 +55,7 @@ RATIONALE RULES:
47
55
  1. The rationale provides context for WHY this requirement exists
48
56
  2. Rationale MUST NOT introduce new obligations
49
57
  3. Rationale MUST NOT use SHALL/MUST language
50
- 4. Rationale can explain regulatory context, design decisions, or relationships to other requirements
58
+ 4. Rationale can explain regulatory context, design decisions, or relationships
51
59
 
52
60
  LANGUAGE GUIDELINES:
53
61
  - Use "The system SHALL..." for system behaviors
@@ -59,11 +67,12 @@ LANGUAGE GUIDELINES:
59
67
  OUTPUT FORMAT:
60
68
  Return a JSON object with:
61
69
  - "rationale": A paragraph explaining the requirement's purpose (no SHALL language)
62
- - "assertions": An array of strings, each being a complete assertion starting with the subject and SHALL
70
+ - "assertions": An array of strings, each a complete assertion with subject and SHALL
63
71
 
64
72
  Example output:
65
73
  {
66
- "rationale": "This requirement ensures complete audit trails for regulatory compliance. FDA 21 CFR Part 11 mandates that electronic records maintain tamper-evident histories of all modifications.",
74
+ "rationale": "This requirement ensures complete audit trails for regulatory compliance. \
75
+ FDA 21 CFR Part 11 mandates tamper-evident histories of all modifications.",
67
76
  "assertions": [
68
77
  "The system SHALL store all data changes as immutable events.",
69
78
  "The system SHALL preserve the complete history of all modifications.",
@@ -80,7 +89,7 @@ def build_user_prompt(
80
89
  status: str,
81
90
  implements: list,
82
91
  body: str,
83
- rationale: str = ""
92
+ rationale: str = "",
84
93
  ) -> str:
85
94
  """
86
95
  Build the user prompt for reformatting a requirement.
@@ -118,6 +127,7 @@ CURRENT RATIONALE:
118
127
  """
119
128
 
120
129
  prompt += """
121
- Extract all obligations and convert them to labeled assertions. Return ONLY the JSON object with "rationale" and "assertions" fields."""
130
+ Extract all obligations and convert them to labeled assertions. \
131
+ Return ONLY the JSON object with "rationale" and "assertions" fields."""
122
132
 
123
133
  return prompt
@@ -12,13 +12,11 @@ import sys
12
12
  from typing import List, Optional, Tuple
13
13
 
14
14
  from elspais.reformat.hierarchy import RequirementNode
15
- from elspais.reformat.prompts import REFORMAT_SYSTEM_PROMPT, JSON_SCHEMA_STR, build_user_prompt
15
+ from elspais.reformat.prompts import JSON_SCHEMA_STR, REFORMAT_SYSTEM_PROMPT, build_user_prompt
16
16
 
17
17
 
18
18
  def reformat_requirement(
19
- node: RequirementNode,
20
- model: str = "sonnet",
21
- verbose: bool = False
19
+ node: RequirementNode, model: str = "sonnet", verbose: bool = False
22
20
  ) -> Tuple[Optional[dict], bool, str]:
23
21
  """
24
22
  Use Claude CLI to reformat a requirement.
@@ -40,30 +38,32 @@ def reformat_requirement(
40
38
  status=node.status,
41
39
  implements=node.implements,
42
40
  body=node.body,
43
- rationale=node.rationale
41
+ rationale=node.rationale,
44
42
  )
45
43
 
46
44
  # Build the claude command
47
45
  cmd = [
48
- 'claude',
49
- '-p', # Print mode (non-interactive)
50
- '--output-format', 'json',
51
- '--json-schema', JSON_SCHEMA_STR,
52
- '--system-prompt', REFORMAT_SYSTEM_PROMPT,
53
- '--tools', '', # Disable all tools
54
- '--model', model,
55
- user_prompt
46
+ "claude",
47
+ "-p", # Print mode (non-interactive)
48
+ "--output-format",
49
+ "json",
50
+ "--json-schema",
51
+ JSON_SCHEMA_STR,
52
+ "--system-prompt",
53
+ REFORMAT_SYSTEM_PROMPT,
54
+ "--tools",
55
+ "", # Disable all tools
56
+ "--model",
57
+ model,
58
+ user_prompt,
56
59
  ]
57
60
 
58
61
  if verbose:
59
- print(f" Running: claude -p --output-format json ...", file=sys.stderr)
62
+ print(" Running: claude -p --output-format json ...", file=sys.stderr)
60
63
 
61
64
  try:
62
65
  result = subprocess.run(
63
- cmd,
64
- capture_output=True,
65
- text=True,
66
- timeout=120 # 2 minute timeout
66
+ cmd, capture_output=True, text=True, timeout=120 # 2 minute timeout
67
67
  )
68
68
 
69
69
  if result.returncode != 0:
@@ -105,29 +105,33 @@ def parse_claude_response(response: str) -> Optional[dict]:
105
105
  data = json.loads(response)
106
106
 
107
107
  # Check for error
108
- if data.get('is_error') or data.get('subtype') == 'error':
108
+ if data.get("is_error") or data.get("subtype") == "error":
109
109
  return None
110
110
 
111
111
  # The structured output is in 'structured_output' field
112
- if 'structured_output' in data:
113
- structured = data['structured_output']
114
- if isinstance(structured, dict) and 'rationale' in structured and 'assertions' in structured:
112
+ if "structured_output" in data:
113
+ structured = data["structured_output"]
114
+ if (
115
+ isinstance(structured, dict)
116
+ and "rationale" in structured
117
+ and "assertions" in structured
118
+ ):
115
119
  return structured
116
120
 
117
121
  # Fallback: Direct result (if schema not used)
118
- if 'rationale' in data and 'assertions' in data:
122
+ if "rationale" in data and "assertions" in data:
119
123
  return data
120
124
 
121
125
  # Fallback: Wrapped in result field
122
- if 'result' in data:
123
- result = data['result']
124
- if isinstance(result, dict) and 'rationale' in result:
126
+ if "result" in data:
127
+ result = data["result"]
128
+ if isinstance(result, dict) and "rationale" in result:
125
129
  return result
126
130
  # Result might be a JSON string
127
131
  if isinstance(result, str) and result.strip():
128
132
  try:
129
133
  parsed = json.loads(result)
130
- if 'rationale' in parsed:
134
+ if "rationale" in parsed:
131
135
  return parsed
132
136
  except json.JSONDecodeError:
133
137
  pass
@@ -137,13 +141,13 @@ def parse_claude_response(response: str) -> Optional[dict]:
137
141
  except json.JSONDecodeError:
138
142
  # Try to extract JSON from the response
139
143
  try:
140
- json_start = response.find('{')
141
- json_end = response.rfind('}') + 1
144
+ json_start = response.find("{")
145
+ json_end = response.rfind("}") + 1
142
146
  if json_start >= 0 and json_end > json_start:
143
147
  parsed = json.loads(response[json_start:json_end])
144
- if 'structured_output' in parsed:
145
- return parsed['structured_output']
146
- if 'rationale' in parsed and 'assertions' in parsed:
148
+ if "structured_output" in parsed:
149
+ return parsed["structured_output"]
150
+ if "rationale" in parsed and "assertions" in parsed:
147
151
  return parsed
148
152
  except json.JSONDecodeError:
149
153
  pass
@@ -157,7 +161,7 @@ def assemble_new_format(
157
161
  status: str,
158
162
  implements: List[str],
159
163
  rationale: str,
160
- assertions: List[str]
164
+ assertions: List[str],
161
165
  ) -> str:
162
166
  """
163
167
  Assemble the new format requirement markdown.
@@ -200,11 +204,11 @@ def assemble_new_format(
200
204
 
201
205
  # Label assertions A, B, C, etc.
202
206
  for i, assertion in enumerate(assertions):
203
- label = chr(ord('A') + i)
207
+ label = chr(ord("A") + i)
204
208
  # Clean up assertion text
205
209
  assertion_text = assertion.strip()
206
210
  # Remove any existing label if present
207
- if len(assertion_text) > 2 and assertion_text[1] == '.' and assertion_text[0].isupper():
211
+ if len(assertion_text) > 2 and assertion_text[1] == "." and assertion_text[0].isupper():
208
212
  assertion_text = assertion_text[2:].strip()
209
213
  lines.append(f"{label}. {assertion_text}")
210
214
 
@@ -219,9 +223,7 @@ def assemble_new_format(
219
223
 
220
224
 
221
225
  def validate_reformatted_content(
222
- original: RequirementNode,
223
- rationale: str,
224
- assertions: List[str]
226
+ original: RequirementNode, rationale: str, assertions: List[str]
225
227
  ) -> Tuple[bool, List[str]]:
226
228
  """
227
229
  Validate that reformatted content is well-formed.
@@ -243,12 +245,12 @@ def validate_reformatted_content(
243
245
 
244
246
  # Check each assertion uses SHALL
245
247
  for i, assertion in enumerate(assertions):
246
- label = chr(ord('A') + i)
247
- if 'SHALL' not in assertion.upper():
248
+ label = chr(ord("A") + i)
249
+ if "SHALL" not in assertion.upper():
248
250
  warnings.append(f"Assertion {label} missing SHALL keyword")
249
251
 
250
252
  # Check rationale doesn't use SHALL
251
- if 'SHALL' in rationale.upper():
253
+ if "SHALL" in rationale.upper():
252
254
  warnings.append("Rationale contains SHALL (should be non-normative)")
253
255
 
254
256
  # Check assertion count
@@ -69,8 +69,6 @@ def parse_yaml(content: str) -> Dict[str, Any]:
69
69
  current_key: Optional[str] = None
70
70
  current_list: Optional[List[Dict]] = None
71
71
  current_dict: Optional[Dict[str, Any]] = None
72
- list_key: Optional[str] = None
73
- indent_stack: List[tuple] = [] # (indent_level, container)
74
72
 
75
73
  lines = content.split("\n")
76
74
 
@@ -11,7 +11,7 @@ This package provides test-to-requirement mapping and coverage analysis:
11
11
  from elspais.testing.config import TestingConfig
12
12
  from elspais.testing.mapper import RequirementTestData, TestMapper, TestMappingResult
13
13
  from elspais.testing.result_parser import ResultParser, TestResult, TestStatus
14
- from elspais.testing.scanner import TestReference, TestScanResult, TestScanner
14
+ from elspais.testing.scanner import TestReference, TestScanner, TestScanResult
15
15
 
16
16
  __all__ = [
17
17
  "TestingConfig",
@@ -10,7 +10,7 @@ import xml.etree.ElementTree as ET
10
10
  from dataclasses import dataclass, field
11
11
  from enum import Enum
12
12
  from pathlib import Path
13
- from typing import Any, Dict, List, Optional, Set
13
+ from typing import List, Optional, Set
14
14
 
15
15
 
16
16
  class TestStatus(Enum):
@@ -143,7 +143,7 @@ class ResultParser:
143
143
  tree = ET.parse(file_path)
144
144
  root = tree.getroot()
145
145
  except ET.ParseError as e:
146
- raise ValueError(f"Invalid XML: {e}")
146
+ raise ValueError(f"Invalid XML: {e}") from e
147
147
 
148
148
  # Handle both <testsuites> and <testsuite> as root
149
149
  if root.tag == "testsuites":
@@ -181,15 +181,17 @@ class ResultParser:
181
181
  # Extract requirement IDs from test name
182
182
  req_ids = self._extract_requirement_ids(test_name, classname)
183
183
 
184
- results.append(TestResult(
185
- test_name=test_name,
186
- classname=classname,
187
- status=status,
188
- requirement_ids=req_ids,
189
- result_file=file_path,
190
- duration=duration,
191
- message=message,
192
- ))
184
+ results.append(
185
+ TestResult(
186
+ test_name=test_name,
187
+ classname=classname,
188
+ status=status,
189
+ requirement_ids=req_ids,
190
+ result_file=file_path,
191
+ duration=duration,
192
+ message=message,
193
+ )
194
+ )
193
195
 
194
196
  return results
195
197
 
@@ -209,7 +211,7 @@ class ResultParser:
209
211
  with open(file_path, encoding="utf-8") as f:
210
212
  data = json.load(f)
211
213
  except json.JSONDecodeError as e:
212
- raise ValueError(f"Invalid JSON: {e}")
214
+ raise ValueError(f"Invalid JSON: {e}") from e
213
215
 
214
216
  # Handle pytest-json-report format
215
217
  tests = data.get("tests", [])
@@ -241,15 +243,17 @@ class ResultParser:
241
243
  # Extract requirement IDs
242
244
  req_ids = self._extract_requirement_ids(test_name, classname)
243
245
 
244
- results.append(TestResult(
245
- test_name=test_name,
246
- classname=classname,
247
- status=status,
248
- requirement_ids=req_ids,
249
- result_file=file_path,
250
- duration=duration,
251
- message=message,
252
- ))
246
+ results.append(
247
+ TestResult(
248
+ test_name=test_name,
249
+ classname=classname,
250
+ status=status,
251
+ requirement_ids=req_ids,
252
+ result_file=file_path,
253
+ duration=duration,
254
+ message=message,
255
+ )
256
+ )
253
257
 
254
258
  return results
255
259
 
@@ -13,8 +13,8 @@ Optional dependencies:
13
13
  - pip install elspais[trace-review] for review server (requires flask)
14
14
  """
15
15
 
16
- from elspais.trace_view.models import TraceViewRequirement, TestInfo, GitChangeInfo
17
16
  from elspais.trace_view.generators.base import TraceViewGenerator
17
+ from elspais.trace_view.models import GitChangeInfo, TestInfo, TraceViewRequirement
18
18
 
19
19
  __all__ = [
20
20
  "TraceViewRequirement",
@@ -30,12 +30,14 @@ __all__ = [
30
30
  def generate_markdown(requirements, **kwargs):
31
31
  """Generate Markdown traceability matrix."""
32
32
  from elspais.trace_view.generators.markdown import generate_markdown as _gen
33
+
33
34
  return _gen(requirements, **kwargs)
34
35
 
35
36
 
36
37
  def generate_csv(requirements, **kwargs):
37
38
  """Generate CSV traceability matrix."""
38
39
  from elspais.trace_view.generators.csv import generate_csv as _gen
40
+
39
41
  return _gen(requirements, **kwargs)
40
42
 
41
43
 
@@ -48,7 +50,6 @@ def generate_html(requirements, **kwargs):
48
50
  from elspais.trace_view.html import HTMLGenerator
49
51
  except ImportError as e:
50
52
  raise ImportError(
51
- "HTML generation requires Jinja2. "
52
- "Install with: pip install elspais[trace-view]"
53
+ "HTML generation requires Jinja2. " "Install with: pip install elspais[trace-view]"
53
54
  ) from e
54
55
  return HTMLGenerator(requirements, **kwargs).generate()
@@ -5,7 +5,7 @@ Provides functions to calculate implementation coverage and status
5
5
  for requirements.
6
6
  """
7
7
 
8
- from typing import Dict, List, Union
8
+ from typing import Dict, List
9
9
 
10
10
  from elspais.trace_view.models import TraceViewRequirement
11
11
 
@@ -124,9 +124,7 @@ def get_implementation_status(requirements: ReqDict, req_id: str) -> str:
124
124
  return "Partial"
125
125
 
126
126
 
127
- def generate_coverage_report(
128
- requirements: ReqDict, get_status_fn=None
129
- ) -> str:
127
+ def generate_coverage_report(requirements: ReqDict, get_status_fn=None) -> str:
130
128
  """Generate text-based coverage report with summary statistics.
131
129
 
132
130
  Args:
@@ -141,7 +139,9 @@ def generate_coverage_report(
141
139
  - Breakdown by implementation status (Full/Partial/Unimplemented)
142
140
  """
143
141
  if get_status_fn is None:
144
- get_status_fn = lambda req_id: get_implementation_status(requirements, req_id)
142
+
143
+ def get_status_fn(req_id):
144
+ return get_implementation_status(requirements, req_id)
145
145
 
146
146
  lines = []
147
147
  lines.append("=== Coverage Report ===")
@@ -6,7 +6,7 @@ Provides Markdown and CSV generators (no dependencies).
6
6
  HTML generator is in the html/ subpackage (requires jinja2).
7
7
  """
8
8
 
9
- from elspais.trace_view.generators.markdown import generate_markdown
10
9
  from elspais.trace_view.generators.csv import generate_csv
10
+ from elspais.trace_view.generators.markdown import generate_markdown
11
11
 
12
12
  __all__ = ["generate_markdown", "generate_csv"]