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.
- elspais/__init__.py +1 -1
- elspais/cli.py +29 -10
- elspais/commands/analyze.py +5 -6
- elspais/commands/changed.py +2 -6
- elspais/commands/config_cmd.py +4 -4
- elspais/commands/edit.py +32 -36
- elspais/commands/hash_cmd.py +24 -18
- elspais/commands/index.py +8 -7
- elspais/commands/init.py +4 -4
- elspais/commands/reformat_cmd.py +32 -43
- elspais/commands/rules_cmd.py +6 -2
- elspais/commands/trace.py +23 -19
- elspais/commands/validate.py +8 -10
- elspais/config/defaults.py +7 -1
- elspais/core/content_rules.py +0 -1
- elspais/core/git.py +4 -10
- elspais/core/parser.py +55 -56
- elspais/core/patterns.py +2 -6
- elspais/core/rules.py +10 -15
- elspais/mcp/__init__.py +2 -0
- elspais/mcp/context.py +1 -0
- elspais/mcp/serializers.py +1 -1
- elspais/mcp/server.py +54 -39
- elspais/reformat/__init__.py +13 -13
- elspais/reformat/detector.py +9 -16
- elspais/reformat/hierarchy.py +8 -7
- elspais/reformat/line_breaks.py +36 -38
- elspais/reformat/prompts.py +22 -12
- elspais/reformat/transformer.py +43 -41
- elspais/sponsors/__init__.py +0 -2
- elspais/testing/__init__.py +1 -1
- elspais/testing/result_parser.py +25 -21
- elspais/trace_view/__init__.py +4 -3
- elspais/trace_view/coverage.py +5 -5
- elspais/trace_view/generators/__init__.py +1 -1
- elspais/trace_view/generators/base.py +17 -12
- elspais/trace_view/generators/csv.py +2 -6
- elspais/trace_view/generators/markdown.py +3 -8
- elspais/trace_view/html/__init__.py +4 -2
- elspais/trace_view/html/generator.py +423 -289
- elspais/trace_view/models.py +25 -0
- elspais/trace_view/review/__init__.py +21 -18
- elspais/trace_view/review/branches.py +114 -121
- elspais/trace_view/review/models.py +232 -237
- elspais/trace_view/review/position.py +53 -71
- elspais/trace_view/review/server.py +264 -288
- elspais/trace_view/review/status.py +43 -58
- elspais/trace_view/review/storage.py +48 -72
- {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/METADATA +1 -1
- {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/RECORD +53 -53
- {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/WHEEL +0 -0
- {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/entry_points.txt +0 -0
- {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/licenses/LICENSE +0 -0
elspais/reformat/prompts.py
CHANGED
|
@@ -14,23 +14,31 @@ JSON_SCHEMA = {
|
|
|
14
14
|
"properties": {
|
|
15
15
|
"rationale": {
|
|
16
16
|
"type": "string",
|
|
17
|
-
"description":
|
|
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":
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
elspais/reformat/transformer.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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(
|
|
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(
|
|
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
|
|
113
|
-
structured = data[
|
|
114
|
-
if
|
|
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
|
|
122
|
+
if "rationale" in data and "assertions" in data:
|
|
119
123
|
return data
|
|
120
124
|
|
|
121
125
|
# Fallback: Wrapped in result field
|
|
122
|
-
if
|
|
123
|
-
result = data[
|
|
124
|
-
if isinstance(result, dict) and
|
|
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
|
|
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(
|
|
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
|
|
145
|
-
return parsed[
|
|
146
|
-
if
|
|
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(
|
|
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] ==
|
|
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(
|
|
247
|
-
if
|
|
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
|
|
253
|
+
if "SHALL" in rationale.upper():
|
|
252
254
|
warnings.append("Rationale contains SHALL (should be non-normative)")
|
|
253
255
|
|
|
254
256
|
# Check assertion count
|
elspais/sponsors/__init__.py
CHANGED
|
@@ -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
|
|
elspais/testing/__init__.py
CHANGED
|
@@ -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,
|
|
14
|
+
from elspais.testing.scanner import TestReference, TestScanner, TestScanResult
|
|
15
15
|
|
|
16
16
|
__all__ = [
|
|
17
17
|
"TestingConfig",
|
elspais/testing/result_parser.py
CHANGED
|
@@ -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
|
|
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(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
elspais/trace_view/__init__.py
CHANGED
|
@@ -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()
|
elspais/trace_view/coverage.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
324
|
-
|
|
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
|
)
|