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/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(
|
|
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(
|
|
146
|
-
"
|
|
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
elspais/mcp/serializers.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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),
|
elspais/reformat/__init__.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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__ = [
|
elspais/reformat/detector.py
CHANGED
|
@@ -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
|
|
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(
|
|
64
|
-
r
|
|
65
|
-
|
|
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
|
|
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
|
|
elspais/reformat/hierarchy.py
CHANGED
|
@@ -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
|
|
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.
|
|
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(
|
|
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
|
|
218
|
-
from elspais.core.patterns import
|
|
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:
|
elspais/reformat/line_breaks.py
CHANGED
|
@@ -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(
|
|
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
|
|
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 (
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
114
|
+
if re.match(r"\*\*Level\*\*:", stripped):
|
|
113
115
|
return True
|
|
114
116
|
|
|
115
117
|
# End marker
|
|
116
|
-
if stripped.startswith(
|
|
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 =
|
|
145
|
+
joined = " ".join(line.strip() for line in lines if line.strip())
|
|
144
146
|
# Collapse multiple spaces
|
|
145
|
-
return re.sub(r
|
|
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
|
|
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(
|
|
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
|
|
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 (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|