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
@@ -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"]
@@ -9,14 +9,11 @@ requirement parsing, implementation scanning, and output generation.
9
9
  from pathlib import Path
10
10
  from typing import Dict, List, Optional
11
11
 
12
- from elspais.config.loader import find_config_file, load_config, get_spec_directories
13
12
  from elspais.config.defaults import DEFAULT_CONFIG
13
+ from elspais.config.loader import find_config_file, get_spec_directories, load_config
14
+ from elspais.core.git import get_git_changes
14
15
  from elspais.core.parser import RequirementParser
15
16
  from elspais.core.patterns import PatternConfig
16
- from elspais.core.git import get_git_changes, GitChangeInfo
17
-
18
- from elspais.trace_view.models import TraceViewRequirement, GitChangeInfo as TVGitChangeInfo
19
- from elspais.trace_view.scanning import scan_implementation_files
20
17
  from elspais.trace_view.coverage import (
21
18
  calculate_coverage,
22
19
  generate_coverage_report,
@@ -24,6 +21,9 @@ from elspais.trace_view.coverage import (
24
21
  )
25
22
  from elspais.trace_view.generators.csv import generate_csv, generate_planning_csv
26
23
  from elspais.trace_view.generators.markdown import generate_markdown
24
+ from elspais.trace_view.models import GitChangeInfo as TVGitChangeInfo
25
+ from elspais.trace_view.models import TraceViewRequirement
26
+ from elspais.trace_view.scanning import scan_implementation_files
27
27
 
28
28
 
29
29
  class TraceViewGenerator:
@@ -93,7 +93,7 @@ class TraceViewGenerator:
93
93
 
94
94
  # Parse requirements
95
95
  if not quiet:
96
- print(f"Scanning for requirements...")
96
+ print("Scanning for requirements...")
97
97
  self._parse_requirements(quiet)
98
98
 
99
99
  if not self.requirements:
@@ -187,9 +187,7 @@ class TraceViewGenerator:
187
187
 
188
188
  # Report branch changes vs main
189
189
  if not quiet and git_changes.branch_changed_files:
190
- spec_branch = [
191
- f for f in git_changes.branch_changed_files if f.startswith("spec/")
192
- ]
190
+ spec_branch = [f for f in git_changes.branch_changed_files if f.startswith("spec/")]
193
191
  if spec_branch:
194
192
  print(f"Spec files changed vs main: {len(spec_branch)}")
195
193
 
@@ -298,7 +296,9 @@ class TraceViewGenerator:
298
296
  cycle_count += 1
299
297
 
300
298
  if not quiet and cycle_count > 0:
301
- print(f" Warning: {cycle_count} requirements marked as cyclic (shown as orphaned items)")
299
+ print(
300
+ f" Warning: {cycle_count} requirements marked as cyclic (shown as orphaned items)"
301
+ )
302
302
 
303
303
  def _calculate_base_path(self, output_file: Path):
304
304
  """Calculate relative path from output file location to repo root."""
@@ -320,8 +320,13 @@ class TraceViewGenerator:
320
320
 
321
321
  def generate_planning_csv(self) -> str:
322
322
  """Generate planning CSV with actionable requirements."""
323
- get_status = lambda req_id: get_implementation_status(self.requirements, req_id)
324
- calc_coverage = lambda req_id: calculate_coverage(self.requirements, req_id)
323
+
324
+ def get_status(req_id):
325
+ return get_implementation_status(self.requirements, req_id)
326
+
327
+ def calc_coverage(req_id):
328
+ return calculate_coverage(self.requirements, req_id)
329
+
325
330
  return generate_planning_csv(self.requirements, get_status, calc_coverage)
326
331
 
327
332
  def generate_coverage_report(self) -> str:
@@ -90,14 +90,10 @@ def generate_planning_csv(
90
90
  writer = csv.writer(output)
91
91
 
92
92
  # Header
93
- writer.writerow(
94
- ["REQ ID", "Title", "Level", "Status", "Impl Status", "Coverage", "Code Refs"]
95
- )
93
+ writer.writerow(["REQ ID", "Title", "Level", "Status", "Impl Status", "Coverage", "Code Refs"])
96
94
 
97
95
  # Filter to actionable requirements (Active or Draft status)
98
- actionable_reqs = [
99
- req for req in requirements.values() if req.status in ["Active", "Draft"]
100
- ]
96
+ actionable_reqs = [req for req in requirements.values() if req.status in ["Active", "Draft"]]
101
97
 
102
98
  # Sort by ID
103
99
  actionable_reqs.sort(key=lambda r: r.id)
@@ -8,8 +8,8 @@ import sys
8
8
  from datetime import datetime
9
9
  from typing import Dict, List, Optional
10
10
 
11
- from elspais.trace_view.models import TraceViewRequirement
12
11
  from elspais.trace_view.coverage import count_by_level, find_orphaned_requirements
12
+ from elspais.trace_view.models import TraceViewRequirement
13
13
 
14
14
 
15
15
  def generate_legend_markdown() -> str:
@@ -83,9 +83,7 @@ def generate_markdown(
83
83
  lines.append("\n## Orphaned Requirements\n")
84
84
  lines.append("*(Requirements not linked from any parent)*\n")
85
85
  for req in orphaned:
86
- lines.append(
87
- f"- **REQ-{req.id}**: {req.title} ({req.level}) - {req.display_filename}"
88
- )
86
+ lines.append(f"- **REQ-{req.id}**: {req.title} ({req.level}) - {req.display_filename}")
89
87
 
90
88
  return "\n".join(lines)
91
89
 
@@ -117,10 +115,7 @@ def format_req_tree_md(
117
115
  cycle_path = ancestor_path + [req.id]
118
116
  cycle_str = " -> ".join([f"REQ-{rid}" for rid in cycle_path])
119
117
  print(f"Warning: CYCLE DETECTED: {cycle_str}", file=sys.stderr)
120
- return (
121
- " " * indent
122
- + f"- **CYCLE DETECTED**: REQ-{req.id} (path: {cycle_str})"
123
- )
118
+ return " " * indent + f"- **CYCLE DETECTED**: REQ-{req.id} (path: {cycle_str})"
124
119
 
125
120
  # Safety depth limit
126
121
  MAX_DEPTH = 50
@@ -5,9 +5,11 @@ elspais.trace_view.html - Interactive HTML generation.
5
5
  Requires: pip install elspais[trace-view]
6
6
  """
7
7
 
8
+
8
9
  def _check_jinja2():
9
10
  try:
10
11
  import jinja2 # noqa: F401
12
+
11
13
  return True
12
14
  except ImportError:
13
15
  return False
@@ -17,6 +19,7 @@ JINJA2_AVAILABLE = _check_jinja2()
17
19
 
18
20
  if JINJA2_AVAILABLE:
19
21
  from elspais.trace_view.html.generator import HTMLGenerator
22
+
20
23
  __all__ = ["HTMLGenerator", "JINJA2_AVAILABLE"]
21
24
  else:
22
25
  __all__ = ["JINJA2_AVAILABLE"]
@@ -26,6 +29,5 @@ else:
26
29
 
27
30
  def __init__(self, *args, **kwargs):
28
31
  raise ImportError(
29
- "HTMLGenerator requires Jinja2. "
30
- "Install with: pip install elspais[trace-view]"
32
+ "HTMLGenerator requires Jinja2. " "Install with: pip install elspais[trace-view]"
31
33
  )