elspais 0.11.2__py3-none-any.whl → 0.43.5__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 -10
- elspais/{sponsors/__init__.py → associates.py} +102 -56
- elspais/cli.py +366 -69
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +118 -169
- elspais/commands/changed.py +12 -23
- elspais/commands/config_cmd.py +10 -13
- elspais/commands/edit.py +33 -13
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +161 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -115
- elspais/commands/init.py +99 -22
- elspais/commands/reformat_cmd.py +41 -433
- elspais/commands/rules_cmd.py +2 -2
- elspais/commands/trace.py +443 -324
- elspais/commands/validate.py +193 -411
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -2
- elspais/docs/cli/assertions.md +67 -0
- elspais/docs/cli/commands.md +304 -0
- elspais/docs/cli/config.md +262 -0
- elspais/docs/cli/format.md +66 -0
- elspais/docs/cli/git.md +45 -0
- elspais/docs/cli/health.md +190 -0
- elspais/docs/cli/hierarchy.md +60 -0
- elspais/docs/cli/ignore.md +72 -0
- elspais/docs/cli/mcp.md +245 -0
- elspais/docs/cli/quickstart.md +58 -0
- elspais/docs/cli/traceability.md +89 -0
- elspais/docs/cli/validation.md +96 -0
- elspais/graph/GraphNode.py +383 -0
- elspais/graph/__init__.py +40 -0
- elspais/graph/annotators.py +927 -0
- elspais/graph/builder.py +1886 -0
- elspais/graph/deserializer.py +248 -0
- elspais/graph/factory.py +284 -0
- elspais/graph/metrics.py +127 -0
- elspais/graph/mutations.py +161 -0
- elspais/graph/parsers/__init__.py +156 -0
- elspais/graph/parsers/code.py +213 -0
- elspais/graph/parsers/comments.py +112 -0
- elspais/graph/parsers/config_helpers.py +29 -0
- elspais/graph/parsers/heredocs.py +225 -0
- elspais/graph/parsers/journey.py +131 -0
- elspais/graph/parsers/remainder.py +79 -0
- elspais/graph/parsers/requirement.py +347 -0
- elspais/graph/parsers/results/__init__.py +6 -0
- elspais/graph/parsers/results/junit_xml.py +229 -0
- elspais/graph/parsers/results/pytest_json.py +313 -0
- elspais/graph/parsers/test.py +305 -0
- elspais/graph/relations.py +78 -0
- elspais/graph/serialize.py +216 -0
- elspais/html/__init__.py +8 -0
- elspais/html/generator.py +731 -0
- elspais/html/templates/trace_view.html.j2 +2151 -0
- elspais/mcp/__init__.py +45 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +1998 -244
- elspais/testing/__init__.py +3 -3
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/scanner.py +301 -12
- elspais/utilities/__init__.py +1 -0
- elspais/utilities/docs_loader.py +115 -0
- elspais/utilities/git.py +607 -0
- elspais/{core → utilities}/hasher.py +8 -22
- elspais/utilities/md_renderer.py +189 -0
- elspais/{core → utilities}/patterns.py +56 -51
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
- elspais-0.43.5.dist-info/RECORD +80 -0
- elspais/config/defaults.py +0 -179
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -346
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -639
- elspais/core/rules.py +0 -509
- elspais/mcp/context.py +0 -172
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -112
- elspais/reformat/hierarchy.py +0 -247
- elspais/reformat/line_breaks.py +0 -218
- elspais/reformat/prompts.py +0 -133
- elspais/reformat/transformer.py +0 -266
- elspais/trace_view/__init__.py +0 -55
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -334
- elspais/trace_view/generators/csv.py +0 -118
- elspais/trace_view/generators/markdown.py +0 -170
- elspais/trace_view/html/__init__.py +0 -33
- elspais/trace_view/html/generator.py +0 -1140
- elspais/trace_view/html/templates/base.html +0 -283
- elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
- elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
- elspais/trace_view/html/templates/components/legend_modal.html +0 -69
- elspais/trace_view/html/templates/components/review_panel.html +0 -118
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
- elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
- elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
- elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
- elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
- elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
- elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
- elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
- elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
- elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
- elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
- elspais/trace_view/html/templates/partials/scripts.js +0 -1741
- elspais/trace_view/html/templates/partials/styles.css +0 -1756
- elspais/trace_view/models.py +0 -378
- elspais/trace_view/review/__init__.py +0 -63
- elspais/trace_view/review/branches.py +0 -1142
- elspais/trace_view/review/models.py +0 -1200
- elspais/trace_view/review/position.py +0 -591
- elspais/trace_view/review/server.py +0 -1032
- elspais/trace_view/review/status.py +0 -455
- elspais/trace_view/review/storage.py +0 -1343
- elspais/trace_view/scanning.py +0 -213
- elspais/trace_view/specs/README.md +0 -84
- elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
- elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
- elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
- elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
- elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
- elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
- elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
- elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
- elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
- elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
- elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
- elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
- elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
- elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
- elspais-0.11.2.dist-info/RECORD +0 -101
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
elspais/testing/__init__.py
CHANGED
|
@@ -5,11 +5,11 @@ This package provides test-to-requirement mapping and coverage analysis:
|
|
|
5
5
|
- TestingConfig: Configuration for test scanning
|
|
6
6
|
- TestScanner: Scans test files for requirement references
|
|
7
7
|
- ResultParser: Parses JUnit XML and pytest JSON results
|
|
8
|
-
-
|
|
8
|
+
- TestCoverageMapper: Orchestrates scanning and result mapping for coverage
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from elspais.testing.config import TestingConfig
|
|
12
|
-
from elspais.testing.mapper import RequirementTestData,
|
|
12
|
+
from elspais.testing.mapper import RequirementTestData, TestCoverageMapper, TestMappingResult
|
|
13
13
|
from elspais.testing.result_parser import ResultParser, TestResult, TestStatus
|
|
14
14
|
from elspais.testing.scanner import TestReference, TestScanner, TestScanResult
|
|
15
15
|
|
|
@@ -21,7 +21,7 @@ __all__ = [
|
|
|
21
21
|
"ResultParser",
|
|
22
22
|
"TestResult",
|
|
23
23
|
"TestStatus",
|
|
24
|
-
"
|
|
24
|
+
"TestCoverageMapper",
|
|
25
25
|
"TestMappingResult",
|
|
26
26
|
"RequirementTestData",
|
|
27
27
|
]
|
elspais/testing/config.py
CHANGED
|
@@ -20,6 +20,7 @@ class TestingConfig:
|
|
|
20
20
|
patterns: File patterns to match test files (e.g., "*_test.py")
|
|
21
21
|
result_files: Glob patterns for test result files (JUnit XML, pytest JSON)
|
|
22
22
|
reference_patterns: Regex patterns to extract requirement IDs from tests
|
|
23
|
+
reference_keyword: Keyword for test references (default: "Validates")
|
|
23
24
|
"""
|
|
24
25
|
|
|
25
26
|
enabled: bool = False
|
|
@@ -27,6 +28,7 @@ class TestingConfig:
|
|
|
27
28
|
patterns: List[str] = field(default_factory=list)
|
|
28
29
|
result_files: List[str] = field(default_factory=list)
|
|
29
30
|
reference_patterns: List[str] = field(default_factory=list)
|
|
31
|
+
reference_keyword: str = "Validates"
|
|
30
32
|
|
|
31
33
|
@classmethod
|
|
32
34
|
def from_dict(cls, data: Dict[str, Any]) -> "TestingConfig":
|
|
@@ -45,4 +47,5 @@ class TestingConfig:
|
|
|
45
47
|
patterns=data.get("patterns", []),
|
|
46
48
|
result_files=data.get("result_files", []),
|
|
47
49
|
reference_patterns=data.get("reference_patterns", []),
|
|
50
|
+
reference_keyword=data.get("reference_keyword", "Validates"),
|
|
48
51
|
)
|
elspais/testing/mapper.py
CHANGED
elspais/testing/scanner.py
CHANGED
|
@@ -3,12 +3,19 @@ elspais.testing.scanner - Test file scanner for requirement references.
|
|
|
3
3
|
|
|
4
4
|
Scans test files for requirement ID references (e.g., REQ-d00001, REQ-p00001-A)
|
|
5
5
|
in function names, docstrings, and comments.
|
|
6
|
+
|
|
7
|
+
Supports configurable reference keyword (default: "Validates") and dynamic
|
|
8
|
+
pattern generation from PatternConfig.
|
|
6
9
|
"""
|
|
7
10
|
|
|
8
11
|
import re
|
|
9
12
|
from dataclasses import dataclass, field
|
|
10
13
|
from pathlib import Path
|
|
11
|
-
from typing import Dict, List, Optional, Set
|
|
14
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Set
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from elspais.graph import GraphNode
|
|
18
|
+
from elspais.utilities.patterns import PatternConfig
|
|
12
19
|
|
|
13
20
|
|
|
14
21
|
@dataclass
|
|
@@ -22,6 +29,7 @@ class TestReference:
|
|
|
22
29
|
test_file: Path to the test file
|
|
23
30
|
test_name: Name of the test function/method if extractable
|
|
24
31
|
line_number: Line number where reference was found
|
|
32
|
+
expected_broken: True if this ref is expected to be broken (from marker)
|
|
25
33
|
"""
|
|
26
34
|
|
|
27
35
|
requirement_id: str
|
|
@@ -29,6 +37,7 @@ class TestReference:
|
|
|
29
37
|
test_file: Path
|
|
30
38
|
test_name: Optional[str] = None
|
|
31
39
|
line_number: int = 0
|
|
40
|
+
expected_broken: bool = False
|
|
32
41
|
|
|
33
42
|
|
|
34
43
|
@dataclass
|
|
@@ -40,11 +49,13 @@ class TestScanResult:
|
|
|
40
49
|
references: Mapping of requirement IDs to their test references
|
|
41
50
|
files_scanned: Number of test files scanned
|
|
42
51
|
errors: List of errors encountered during scanning
|
|
52
|
+
suppressed_count: Count of refs marked as expected_broken (for logging)
|
|
43
53
|
"""
|
|
44
54
|
|
|
45
55
|
references: Dict[str, List[TestReference]] = field(default_factory=dict)
|
|
46
56
|
files_scanned: int = 0
|
|
47
57
|
errors: List[str] = field(default_factory=list)
|
|
58
|
+
suppressed_count: int = 0
|
|
48
59
|
|
|
49
60
|
def add_reference(self, ref: TestReference) -> None:
|
|
50
61
|
"""Add a test reference, rolling up assertion-level refs to parent."""
|
|
@@ -55,6 +66,75 @@ class TestScanResult:
|
|
|
55
66
|
self.references[req_id].append(ref)
|
|
56
67
|
|
|
57
68
|
|
|
69
|
+
def build_validates_patterns(
|
|
70
|
+
pattern_config: "PatternConfig",
|
|
71
|
+
keyword: str = "Validates",
|
|
72
|
+
) -> List[str]:
|
|
73
|
+
"""
|
|
74
|
+
Build regex patterns for test references using PatternConfig.
|
|
75
|
+
|
|
76
|
+
Generates patterns that match the configured requirement ID format
|
|
77
|
+
with the specified reference keyword (e.g., "Validates:").
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
pattern_config: Configuration for requirement ID patterns.
|
|
81
|
+
keyword: Reference keyword to match (default: "Validates").
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of regex pattern strings.
|
|
85
|
+
"""
|
|
86
|
+
# Get type IDs from config
|
|
87
|
+
type_ids = pattern_config.get_all_type_ids()
|
|
88
|
+
type_pattern = "|".join(re.escape(t) for t in type_ids if t)
|
|
89
|
+
|
|
90
|
+
# Build ID pattern based on format
|
|
91
|
+
id_format = pattern_config.id_format
|
|
92
|
+
style = id_format.get("style", "numeric")
|
|
93
|
+
|
|
94
|
+
if style == "numeric":
|
|
95
|
+
digits = int(id_format.get("digits", 5))
|
|
96
|
+
leading_zeros = id_format.get("leading_zeros", True)
|
|
97
|
+
if digits > 0 and leading_zeros:
|
|
98
|
+
id_pattern = f"\\d{{{digits}}}"
|
|
99
|
+
elif digits > 0:
|
|
100
|
+
id_pattern = f"\\d{{1,{digits}}}"
|
|
101
|
+
else:
|
|
102
|
+
id_pattern = "\\d+"
|
|
103
|
+
elif style == "named":
|
|
104
|
+
pattern_str = id_format.get("pattern", "[A-Za-z][A-Za-z0-9]+")
|
|
105
|
+
id_pattern = pattern_str
|
|
106
|
+
elif style == "alphanumeric":
|
|
107
|
+
pattern_str = id_format.get("pattern", "[A-Z0-9]+")
|
|
108
|
+
id_pattern = pattern_str
|
|
109
|
+
else:
|
|
110
|
+
id_pattern = "[A-Za-z0-9]+"
|
|
111
|
+
|
|
112
|
+
# Build assertion pattern
|
|
113
|
+
assertion_pattern = pattern_config.get_assertion_label_pattern()
|
|
114
|
+
|
|
115
|
+
# Build the prefix pattern
|
|
116
|
+
prefix = re.escape(pattern_config.prefix)
|
|
117
|
+
|
|
118
|
+
# Create keyword patterns (case insensitive matching)
|
|
119
|
+
keyword_variants = f"(?:{keyword}|{keyword.upper()}|{keyword.lower()})"
|
|
120
|
+
|
|
121
|
+
patterns = []
|
|
122
|
+
|
|
123
|
+
# Pattern for keyword: REQ-p00001 or REQ-p00001-A
|
|
124
|
+
if type_pattern:
|
|
125
|
+
# Full pattern with type code
|
|
126
|
+
patterns.append(
|
|
127
|
+
f"{keyword_variants}[:\\s]+{prefix}-({type_pattern}{id_pattern})(?:-({assertion_pattern}))?"
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
# Pattern without type code
|
|
131
|
+
patterns.append(
|
|
132
|
+
f"{keyword_variants}[:\\s]+{prefix}-({id_pattern})(?:-({assertion_pattern}))?"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return patterns
|
|
136
|
+
|
|
137
|
+
|
|
58
138
|
class TestScanner:
|
|
59
139
|
"""
|
|
60
140
|
Scans test files for requirement ID references.
|
|
@@ -62,10 +142,10 @@ class TestScanner:
|
|
|
62
142
|
Uses configurable patterns to find requirement references in:
|
|
63
143
|
- Test function/method names
|
|
64
144
|
- Docstrings
|
|
65
|
-
- Comments (IMPLEMENTS: patterns)
|
|
145
|
+
- Comments (Validates: or IMPLEMENTS: patterns)
|
|
66
146
|
"""
|
|
67
147
|
|
|
68
|
-
# Default patterns if none configured
|
|
148
|
+
# Default patterns if none configured (HHT-style)
|
|
69
149
|
DEFAULT_PATTERNS = [
|
|
70
150
|
# Test function names: test_REQ_p00001_something or test_p00001_something
|
|
71
151
|
r"test_.*(?:REQ[-_])?([pod]\d{5})(?:[-_]([A-Z]))?",
|
|
@@ -75,7 +155,11 @@ class TestScanner:
|
|
|
75
155
|
r"\bREQ[-_]([pod]\d{5})(?:-([A-Z]))?\b",
|
|
76
156
|
]
|
|
77
157
|
|
|
78
|
-
def __init__(
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
reference_patterns: Optional[List[str]] = None,
|
|
161
|
+
reference_keyword: str = "Validates",
|
|
162
|
+
) -> None:
|
|
79
163
|
"""
|
|
80
164
|
Initialize the scanner with reference patterns.
|
|
81
165
|
|
|
@@ -83,9 +167,34 @@ class TestScanner:
|
|
|
83
167
|
reference_patterns: Regex patterns for extracting requirement IDs.
|
|
84
168
|
Each pattern should have groups for (type+id) and
|
|
85
169
|
optionally (assertion_label).
|
|
170
|
+
reference_keyword: Keyword for test references (default: "Validates").
|
|
171
|
+
Used to build default patterns when reference_patterns
|
|
172
|
+
is not provided.
|
|
86
173
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
174
|
+
self._reference_keyword = reference_keyword
|
|
175
|
+
|
|
176
|
+
if reference_patterns:
|
|
177
|
+
patterns = reference_patterns
|
|
178
|
+
else:
|
|
179
|
+
# Build patterns including the keyword variant
|
|
180
|
+
patterns = self._build_default_patterns(reference_keyword)
|
|
181
|
+
|
|
182
|
+
self._patterns = [re.compile(p, re.IGNORECASE) for p in patterns]
|
|
183
|
+
|
|
184
|
+
def _build_default_patterns(self, keyword: str) -> List[str]:
|
|
185
|
+
"""Build default patterns with the given keyword."""
|
|
186
|
+
keyword_variants = f"(?:{keyword}|{keyword.upper()}|{keyword.lower()})"
|
|
187
|
+
|
|
188
|
+
return [
|
|
189
|
+
# Test function names: test_REQ_p00001_something or test_p00001_something
|
|
190
|
+
r"test_.*(?:REQ[-_])?([pod]\d{5})(?:[-_]([A-Z]))?",
|
|
191
|
+
# Keyword comments: Validates: REQ-p00001 or Validates: REQ-p00001-A
|
|
192
|
+
f"{keyword_variants}[:\\s]+REQ[-_]?([pod]\\d{{5}})(?:-([A-Z]))?",
|
|
193
|
+
# Also support IMPLEMENTS for backward compatibility
|
|
194
|
+
r"(?:IMPLEMENTS|Implements|implements)[:\s]+REQ[-_]?([pod]\d{5})(?:-([A-Z]))?",
|
|
195
|
+
# Direct references: REQ-p00001 or REQ-p00001-A
|
|
196
|
+
r"\bREQ[-_]([pod]\d{5})(?:-([A-Z]))?\b",
|
|
197
|
+
]
|
|
89
198
|
|
|
90
199
|
def scan_directories(
|
|
91
200
|
self,
|
|
@@ -97,6 +206,10 @@ class TestScanner:
|
|
|
97
206
|
"""
|
|
98
207
|
Scan test directories for requirement references.
|
|
99
208
|
|
|
209
|
+
Detects `elspais: expected-broken-links N` markers in file headers
|
|
210
|
+
(supports multiple comment styles) and marks the next N references
|
|
211
|
+
as expected_broken.
|
|
212
|
+
|
|
100
213
|
Args:
|
|
101
214
|
base_path: Project root path
|
|
102
215
|
test_dirs: Glob patterns for test directories (e.g., ["apps/**/test"])
|
|
@@ -104,7 +217,7 @@ class TestScanner:
|
|
|
104
217
|
ignore: Directory names to ignore (e.g., ["node_modules"])
|
|
105
218
|
|
|
106
219
|
Returns:
|
|
107
|
-
TestScanResult with all found references
|
|
220
|
+
TestScanResult with all found references and suppressed_count
|
|
108
221
|
"""
|
|
109
222
|
result = TestScanResult()
|
|
110
223
|
ignore_set = set(ignore or [])
|
|
@@ -134,14 +247,36 @@ class TestScanner:
|
|
|
134
247
|
continue
|
|
135
248
|
seen_files.add(test_file)
|
|
136
249
|
|
|
137
|
-
# Scan the file
|
|
138
|
-
file_refs = self.
|
|
250
|
+
# Scan the file (handles marker detection internally)
|
|
251
|
+
file_refs, suppressed = self._scan_file_with_marker(test_file)
|
|
139
252
|
for ref in file_refs:
|
|
140
253
|
result.add_reference(ref)
|
|
254
|
+
result.suppressed_count += suppressed
|
|
141
255
|
result.files_scanned += 1
|
|
142
256
|
|
|
143
257
|
return result
|
|
144
258
|
|
|
259
|
+
# Marker pattern for expected broken links - multi-language support
|
|
260
|
+
# Matches various comment styles:
|
|
261
|
+
# - # elspais: expected-broken-links N (Python, Shell, Ruby, YAML)
|
|
262
|
+
# - // elspais: expected-broken-links N (JS, TS, Java, C, C++, Go, Rust)
|
|
263
|
+
# - -- elspais: expected-broken-links N (SQL, Lua, Ada)
|
|
264
|
+
# - /* elspais: expected-broken-links N */ (CSS, C-style block comment)
|
|
265
|
+
# - <!-- elspais: expected-broken-links N --> (HTML, XML)
|
|
266
|
+
_EXPECTED_BROKEN_LINKS_PATTERN = re.compile(
|
|
267
|
+
r"(?:"
|
|
268
|
+
r"#|" # Python, Shell, Ruby, YAML
|
|
269
|
+
r"//|" # JS, TS, Java, C, C++, Go, Rust
|
|
270
|
+
r"--|" # SQL, Lua, Ada
|
|
271
|
+
r"/\*|" # CSS, C-style block comment start
|
|
272
|
+
r"<!--" # HTML, XML comment start
|
|
273
|
+
r")\s*elspais:\s*expected-broken-links\s+(\d+)",
|
|
274
|
+
re.IGNORECASE,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Number of header lines to scan for marker
|
|
278
|
+
_MARKER_HEADER_LINES = 20
|
|
279
|
+
|
|
145
280
|
def _scan_file(self, file_path: Path) -> List[TestReference]:
|
|
146
281
|
"""
|
|
147
282
|
Scan a single test file for requirement references.
|
|
@@ -152,16 +287,36 @@ class TestScanner:
|
|
|
152
287
|
Returns:
|
|
153
288
|
List of TestReference objects found in the file
|
|
154
289
|
"""
|
|
290
|
+
refs, _ = self._scan_file_with_marker(file_path)
|
|
291
|
+
return refs
|
|
292
|
+
|
|
293
|
+
def _scan_file_with_marker(self, file_path: Path) -> tuple[List[TestReference], int]:
|
|
294
|
+
"""
|
|
295
|
+
Scan a single test file for requirement references with marker support.
|
|
296
|
+
|
|
297
|
+
Detects the expected-broken-links marker in the file header and marks
|
|
298
|
+
the next N references as expected_broken.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
file_path: Path to the test file
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Tuple of (list of TestReference objects, count of suppressed refs)
|
|
305
|
+
"""
|
|
155
306
|
references: List[TestReference] = []
|
|
156
307
|
|
|
157
308
|
try:
|
|
158
309
|
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
159
310
|
except Exception:
|
|
160
|
-
return references
|
|
311
|
+
return references, 0
|
|
161
312
|
|
|
162
313
|
lines = content.split("\n")
|
|
163
314
|
current_test_name: Optional[str] = None
|
|
164
315
|
|
|
316
|
+
# Detect marker in header and get initial suppress count
|
|
317
|
+
suppress_remaining = self._detect_expected_broken_links_marker(file_path) or 0
|
|
318
|
+
suppressed_count = 0
|
|
319
|
+
|
|
165
320
|
for line_num, line in enumerate(lines, start=1):
|
|
166
321
|
# Track current test function name
|
|
167
322
|
test_match = re.match(r"\s*def\s+(test_\w+)", line)
|
|
@@ -180,7 +335,17 @@ class TestScanner:
|
|
|
180
335
|
assertion_label = groups[1] if len(groups) > 1 else None
|
|
181
336
|
|
|
182
337
|
# Normalize to full requirement ID
|
|
183
|
-
|
|
338
|
+
if type_id.startswith("REQ-") or type_id.startswith("REQ_"):
|
|
339
|
+
req_id = type_id.replace("_", "-")
|
|
340
|
+
else:
|
|
341
|
+
req_id = f"REQ-{type_id}"
|
|
342
|
+
|
|
343
|
+
# Check if we have suppress budget
|
|
344
|
+
expected_broken = False
|
|
345
|
+
if suppress_remaining > 0:
|
|
346
|
+
expected_broken = True
|
|
347
|
+
suppress_remaining -= 1
|
|
348
|
+
suppressed_count += 1
|
|
184
349
|
|
|
185
350
|
ref = TestReference(
|
|
186
351
|
requirement_id=req_id,
|
|
@@ -188,10 +353,38 @@ class TestScanner:
|
|
|
188
353
|
test_file=file_path,
|
|
189
354
|
test_name=current_test_name,
|
|
190
355
|
line_number=line_num,
|
|
356
|
+
expected_broken=expected_broken,
|
|
191
357
|
)
|
|
192
358
|
references.append(ref)
|
|
193
359
|
|
|
194
|
-
return references
|
|
360
|
+
return references, suppressed_count
|
|
361
|
+
|
|
362
|
+
def _detect_expected_broken_links_marker(self, file_path: Path) -> Optional[int]:
|
|
363
|
+
"""
|
|
364
|
+
Detect expected-broken-links marker in file header.
|
|
365
|
+
|
|
366
|
+
Scans the first 20 lines of a file for the marker:
|
|
367
|
+
# elspais: expected-broken-links N
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
file_path: Path to the file to scan
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
The expected count N if marker is found, None otherwise
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
377
|
+
except Exception:
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
lines = content.split("\n")
|
|
381
|
+
# Only scan header area (first N lines)
|
|
382
|
+
for line in lines[: self._MARKER_HEADER_LINES]:
|
|
383
|
+
match = self._EXPECTED_BROKEN_LINKS_PATTERN.search(line)
|
|
384
|
+
if match:
|
|
385
|
+
return int(match.group(1))
|
|
386
|
+
|
|
387
|
+
return None
|
|
195
388
|
|
|
196
389
|
def scan_file(self, file_path: Path) -> List[TestReference]:
|
|
197
390
|
"""
|
|
@@ -204,3 +397,99 @@ class TestScanner:
|
|
|
204
397
|
List of TestReference objects
|
|
205
398
|
"""
|
|
206
399
|
return self._scan_file(file_path)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def create_test_nodes(
|
|
403
|
+
scan_result: TestScanResult,
|
|
404
|
+
repo_root: Path,
|
|
405
|
+
) -> List["GraphNode"]:
|
|
406
|
+
"""
|
|
407
|
+
Convert TestScanResult to GraphNode objects for graph building.
|
|
408
|
+
|
|
409
|
+
Creates a GraphNode for each unique test function that references requirements.
|
|
410
|
+
The _validates_targets metric contains the list of requirement/assertion IDs
|
|
411
|
+
that the test validates. The _expected_broken_targets metric contains targets
|
|
412
|
+
that were marked as expected to be broken via the expected-broken-links marker.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
scan_result: Result from TestScanner.scan_directories()
|
|
416
|
+
repo_root: Repository root for relative path calculation
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
List of GraphNode objects with kind=TEST
|
|
420
|
+
"""
|
|
421
|
+
# NOTE: test_ref data is stored in content dict
|
|
422
|
+
from elspais.graph import GraphNode, NodeKind, SourceLocation
|
|
423
|
+
|
|
424
|
+
# Group references by (file, test_name) to create one node per test
|
|
425
|
+
tests_by_key: Dict[tuple, List[TestReference]] = {}
|
|
426
|
+
|
|
427
|
+
for _req_id, refs in scan_result.references.items():
|
|
428
|
+
for ref in refs:
|
|
429
|
+
key = (str(ref.test_file), ref.test_name)
|
|
430
|
+
if key not in tests_by_key:
|
|
431
|
+
tests_by_key[key] = []
|
|
432
|
+
tests_by_key[key].append(ref)
|
|
433
|
+
|
|
434
|
+
nodes: List[GraphNode] = []
|
|
435
|
+
|
|
436
|
+
for (file_path_str, test_name), refs in tests_by_key.items():
|
|
437
|
+
file_path = Path(file_path_str)
|
|
438
|
+
|
|
439
|
+
# Calculate relative path
|
|
440
|
+
try:
|
|
441
|
+
rel_path = str(file_path.relative_to(repo_root))
|
|
442
|
+
except ValueError:
|
|
443
|
+
rel_path = str(file_path)
|
|
444
|
+
|
|
445
|
+
# Collect all targets this test validates and expected broken targets
|
|
446
|
+
validates_targets: List[str] = []
|
|
447
|
+
expected_broken_targets: List[str] = []
|
|
448
|
+
for ref in refs:
|
|
449
|
+
# Build target ID
|
|
450
|
+
if ref.assertion_label:
|
|
451
|
+
target = f"{ref.requirement_id}-{ref.assertion_label}"
|
|
452
|
+
else:
|
|
453
|
+
target = ref.requirement_id
|
|
454
|
+
if target not in validates_targets:
|
|
455
|
+
validates_targets.append(target)
|
|
456
|
+
# Track expected broken targets separately
|
|
457
|
+
if ref.expected_broken and target not in expected_broken_targets:
|
|
458
|
+
expected_broken_targets.append(target)
|
|
459
|
+
|
|
460
|
+
# Use first ref for line number
|
|
461
|
+
first_ref = refs[0]
|
|
462
|
+
line_num = first_ref.line_number
|
|
463
|
+
|
|
464
|
+
# Create node ID
|
|
465
|
+
node_id = f"TEST:{rel_path}:{test_name or 'unknown'}"
|
|
466
|
+
|
|
467
|
+
# Create label
|
|
468
|
+
label = test_name or file_path.name
|
|
469
|
+
|
|
470
|
+
# Store test_ref data in content dict
|
|
471
|
+
node = GraphNode(
|
|
472
|
+
id=node_id,
|
|
473
|
+
kind=NodeKind.TEST,
|
|
474
|
+
label=label,
|
|
475
|
+
source=SourceLocation(
|
|
476
|
+
path=rel_path,
|
|
477
|
+
line=line_num,
|
|
478
|
+
),
|
|
479
|
+
content={
|
|
480
|
+
"test_ref": {
|
|
481
|
+
"file_path": rel_path,
|
|
482
|
+
"line": line_num,
|
|
483
|
+
"test_name": test_name or "unknown",
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
metrics={
|
|
487
|
+
"_validates_targets": validates_targets,
|
|
488
|
+
"_expected_broken_targets": expected_broken_targets,
|
|
489
|
+
"_test_status": "unknown", # Will be updated from test results
|
|
490
|
+
},
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
nodes.append(node)
|
|
494
|
+
|
|
495
|
+
return nodes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utilities module - Common utilities for elspais."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Documentation loader for CLI docs command.
|
|
2
|
+
|
|
3
|
+
Loads markdown documentation files from docs/cli/ directory.
|
|
4
|
+
Supports both installed package and development repository layouts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Ordered list of documentation topics
|
|
17
|
+
TOPIC_ORDER = [
|
|
18
|
+
"quickstart",
|
|
19
|
+
"format",
|
|
20
|
+
"hierarchy",
|
|
21
|
+
"assertions",
|
|
22
|
+
"traceability",
|
|
23
|
+
"validation",
|
|
24
|
+
"git",
|
|
25
|
+
"config",
|
|
26
|
+
"commands",
|
|
27
|
+
"health",
|
|
28
|
+
"mcp",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_docs_dir() -> Path | None:
|
|
33
|
+
"""Locate the docs/cli directory.
|
|
34
|
+
|
|
35
|
+
Checks two locations:
|
|
36
|
+
1. Package location: <package>/docs/cli (installed via wheel)
|
|
37
|
+
2. Repository location: <repo>/docs/cli (development mode)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Path to docs/cli directory, or None if not found.
|
|
41
|
+
"""
|
|
42
|
+
# Try package location first (installed wheel)
|
|
43
|
+
package_dir = Path(__file__).parent.parent # src/elspais
|
|
44
|
+
package_docs = package_dir / "docs" / "cli"
|
|
45
|
+
if package_docs.is_dir():
|
|
46
|
+
return package_docs
|
|
47
|
+
|
|
48
|
+
# Try repository root (development mode)
|
|
49
|
+
# Walk up from utilities/ to find docs/cli
|
|
50
|
+
repo_root = package_dir.parent.parent # src/../..
|
|
51
|
+
repo_docs = repo_root / "docs" / "cli"
|
|
52
|
+
if repo_docs.is_dir():
|
|
53
|
+
return repo_docs
|
|
54
|
+
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_topic(topic: str) -> str | None:
|
|
59
|
+
"""Load a single documentation topic.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
topic: Topic name (e.g., 'quickstart', 'format').
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Markdown content, or None if topic not found.
|
|
66
|
+
"""
|
|
67
|
+
docs_dir = find_docs_dir()
|
|
68
|
+
if docs_dir is None:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
topic_file = docs_dir / f"{topic}.md"
|
|
72
|
+
if not topic_file.is_file():
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
return topic_file.read_text(encoding="utf-8")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_all_topics() -> str:
|
|
79
|
+
"""Load and concatenate all documentation topics.
|
|
80
|
+
|
|
81
|
+
Returns topics in the defined order, separated by blank lines.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Combined markdown content from all topics.
|
|
85
|
+
"""
|
|
86
|
+
docs_dir = find_docs_dir()
|
|
87
|
+
if docs_dir is None:
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
parts: list[str] = []
|
|
91
|
+
for topic in TOPIC_ORDER:
|
|
92
|
+
content = load_topic(topic)
|
|
93
|
+
if content:
|
|
94
|
+
parts.append(content)
|
|
95
|
+
|
|
96
|
+
return "\n\n".join(parts)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_available_topics() -> list[str]:
|
|
100
|
+
"""Get list of available documentation topics.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of topic names that have corresponding files.
|
|
104
|
+
"""
|
|
105
|
+
docs_dir = find_docs_dir()
|
|
106
|
+
if docs_dir is None:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
available = []
|
|
110
|
+
for topic in TOPIC_ORDER:
|
|
111
|
+
topic_file = docs_dir / f"{topic}.md"
|
|
112
|
+
if topic_file.is_file():
|
|
113
|
+
available.append(topic)
|
|
114
|
+
|
|
115
|
+
return available
|