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
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Pytest JSON parser for test results.
|
|
2
|
+
|
|
3
|
+
This parser extracts test results from pytest JSON format files
|
|
4
|
+
(generated by pytest-json-report or similar plugins).
|
|
5
|
+
Uses the shared reference_config infrastructure for configurable patterns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from elspais.utilities.reference_config import (
|
|
15
|
+
ReferenceConfig,
|
|
16
|
+
ReferenceResolver,
|
|
17
|
+
extract_ids_from_text,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from elspais.utilities.patterns import PatternConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PytestJSONParser:
|
|
25
|
+
"""Parser for Pytest JSON test result files.
|
|
26
|
+
|
|
27
|
+
Parses JSON output from pytest-json-report or similar pytest plugins.
|
|
28
|
+
|
|
29
|
+
Uses configurable patterns from ReferenceConfig for:
|
|
30
|
+
- Separator characters (- _ etc.)
|
|
31
|
+
- Case sensitivity
|
|
32
|
+
- Prefix requirements
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
pattern_config: PatternConfig | None = None,
|
|
38
|
+
reference_resolver: ReferenceResolver | None = None,
|
|
39
|
+
base_path: Path | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Initialize PytestJSONParser with optional configuration.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
pattern_config: Configuration for ID structure. If None, uses defaults.
|
|
45
|
+
reference_resolver: Resolver for file-specific reference config. If None,
|
|
46
|
+
uses default ReferenceConfig.
|
|
47
|
+
base_path: Base path for resolving file-specific configs.
|
|
48
|
+
"""
|
|
49
|
+
self._pattern_config = pattern_config
|
|
50
|
+
self._reference_resolver = reference_resolver
|
|
51
|
+
self._base_path = base_path or Path(".")
|
|
52
|
+
|
|
53
|
+
def _get_pattern_config(self) -> PatternConfig:
|
|
54
|
+
"""Get pattern config from instance or create default.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
PatternConfig to use for parsing.
|
|
58
|
+
"""
|
|
59
|
+
if self._pattern_config is not None:
|
|
60
|
+
return self._pattern_config
|
|
61
|
+
|
|
62
|
+
from elspais.utilities.patterns import PatternConfig
|
|
63
|
+
|
|
64
|
+
return PatternConfig.from_dict(
|
|
65
|
+
{
|
|
66
|
+
"prefix": "REQ",
|
|
67
|
+
"types": {
|
|
68
|
+
"prd": {"id": "p", "name": "PRD"},
|
|
69
|
+
"ops": {"id": "o", "name": "OPS"},
|
|
70
|
+
"dev": {"id": "d", "name": "DEV"},
|
|
71
|
+
},
|
|
72
|
+
"id_format": {"style": "numeric", "digits": 5},
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _get_reference_config(self, source_file: str | None = None) -> ReferenceConfig:
|
|
77
|
+
"""Get reference config for the current file.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
source_file: Optional source file path for file-specific config.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
ReferenceConfig for parsing.
|
|
84
|
+
"""
|
|
85
|
+
if self._reference_resolver is not None and source_file:
|
|
86
|
+
return self._reference_resolver.resolve(Path(source_file), self._base_path)
|
|
87
|
+
|
|
88
|
+
if self._reference_resolver is not None:
|
|
89
|
+
return self._reference_resolver.defaults
|
|
90
|
+
|
|
91
|
+
return ReferenceConfig()
|
|
92
|
+
|
|
93
|
+
def parse(self, content: str, source_path: str) -> list[dict[str, Any]]:
|
|
94
|
+
"""Parse Pytest JSON content and return test result dicts.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
content: JSON file content.
|
|
98
|
+
source_path: Path to the source file.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of test result dictionaries with keys:
|
|
102
|
+
- id: Unique test ID
|
|
103
|
+
- name: Test name
|
|
104
|
+
- classname: Test class/module name
|
|
105
|
+
- status: passed, failed, skipped, or error
|
|
106
|
+
- duration: Test duration in seconds
|
|
107
|
+
- message: Error/failure message (if any)
|
|
108
|
+
- validates: List of requirement IDs this test validates
|
|
109
|
+
"""
|
|
110
|
+
results: list[dict[str, Any]] = []
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
data = json.loads(content)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return results
|
|
116
|
+
|
|
117
|
+
# Handle pytest-json-report format
|
|
118
|
+
if "tests" in data:
|
|
119
|
+
for test in data["tests"]:
|
|
120
|
+
result = self._parse_pytest_json_report_test(test, source_path)
|
|
121
|
+
if result:
|
|
122
|
+
results.append(result)
|
|
123
|
+
# Handle simpler format with just a list of tests
|
|
124
|
+
elif isinstance(data, list):
|
|
125
|
+
for test in data:
|
|
126
|
+
result = self._parse_simple_test(test, source_path)
|
|
127
|
+
if result:
|
|
128
|
+
results.append(result)
|
|
129
|
+
|
|
130
|
+
return results
|
|
131
|
+
|
|
132
|
+
def _parse_pytest_json_report_test(
|
|
133
|
+
self, test: dict[str, Any], source_path: str
|
|
134
|
+
) -> dict[str, Any] | None:
|
|
135
|
+
"""Parse a test from pytest-json-report format.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
test: Test dict from pytest-json-report.
|
|
139
|
+
source_path: Path to the source file.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Parsed test result dict or None.
|
|
143
|
+
"""
|
|
144
|
+
nodeid = test.get("nodeid", "")
|
|
145
|
+
outcome = test.get("outcome", "passed")
|
|
146
|
+
|
|
147
|
+
# Parse nodeid to get module and test name
|
|
148
|
+
# Format: path/to/test.py::TestClass::test_method
|
|
149
|
+
# or: path/to/test.py::test_function
|
|
150
|
+
parts = nodeid.split("::")
|
|
151
|
+
if len(parts) >= 2:
|
|
152
|
+
classname = parts[0]
|
|
153
|
+
name = "::".join(parts[1:])
|
|
154
|
+
else:
|
|
155
|
+
classname = ""
|
|
156
|
+
name = nodeid
|
|
157
|
+
|
|
158
|
+
# Map outcome to status
|
|
159
|
+
status_map = {
|
|
160
|
+
"passed": "passed",
|
|
161
|
+
"failed": "failed",
|
|
162
|
+
"skipped": "skipped",
|
|
163
|
+
"error": "error",
|
|
164
|
+
"xfailed": "skipped", # Expected failure
|
|
165
|
+
"xpassed": "passed", # Unexpected pass
|
|
166
|
+
}
|
|
167
|
+
status = status_map.get(outcome, "passed")
|
|
168
|
+
|
|
169
|
+
# Get duration
|
|
170
|
+
duration = 0.0
|
|
171
|
+
if "duration" in test:
|
|
172
|
+
duration = float(test["duration"])
|
|
173
|
+
elif "call" in test and "duration" in test["call"]:
|
|
174
|
+
duration = float(test["call"]["duration"])
|
|
175
|
+
|
|
176
|
+
# Get message from call or setup/teardown
|
|
177
|
+
message = None
|
|
178
|
+
for phase in ["call", "setup", "teardown"]:
|
|
179
|
+
if phase in test:
|
|
180
|
+
phase_data = test[phase]
|
|
181
|
+
if isinstance(phase_data, dict):
|
|
182
|
+
if "longrepr" in phase_data:
|
|
183
|
+
message = str(phase_data["longrepr"])[:200]
|
|
184
|
+
break
|
|
185
|
+
elif "message" in phase_data:
|
|
186
|
+
message = str(phase_data["message"])[:200]
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
# Extract requirement references
|
|
190
|
+
validates = self._extract_req_ids(f"{classname} {name}", source_path)
|
|
191
|
+
|
|
192
|
+
# Generate stable TEST node ID from classname and name
|
|
193
|
+
test_id = f"test:{classname}::{name}" if classname else f"test::{name}"
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"id": f"{source_path}::{nodeid}",
|
|
197
|
+
"name": name,
|
|
198
|
+
"classname": classname,
|
|
199
|
+
"status": status,
|
|
200
|
+
"duration": duration,
|
|
201
|
+
"message": message,
|
|
202
|
+
"validates": validates,
|
|
203
|
+
"source_path": source_path,
|
|
204
|
+
"test_id": test_id,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
def _parse_simple_test(self, test: dict[str, Any], source_path: str) -> dict[str, Any] | None:
|
|
208
|
+
"""Parse a test from simple list format.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
test: Test dict with name, status, etc.
|
|
212
|
+
source_path: Path to the source file.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Parsed test result dict or None.
|
|
216
|
+
"""
|
|
217
|
+
name = test.get("name", "")
|
|
218
|
+
if not name:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
classname = test.get("classname", test.get("module", ""))
|
|
222
|
+
status = test.get("status", test.get("outcome", "passed"))
|
|
223
|
+
|
|
224
|
+
# Normalize status
|
|
225
|
+
status_map = {
|
|
226
|
+
"pass": "passed",
|
|
227
|
+
"passed": "passed",
|
|
228
|
+
"fail": "failed",
|
|
229
|
+
"failed": "failed",
|
|
230
|
+
"skip": "skipped",
|
|
231
|
+
"skipped": "skipped",
|
|
232
|
+
"error": "error",
|
|
233
|
+
}
|
|
234
|
+
status = status_map.get(status.lower(), "passed")
|
|
235
|
+
|
|
236
|
+
duration = float(test.get("duration", 0))
|
|
237
|
+
message = test.get("message", test.get("longrepr"))
|
|
238
|
+
if message:
|
|
239
|
+
message = str(message)[:200]
|
|
240
|
+
|
|
241
|
+
validates = self._extract_req_ids(f"{classname} {name}", source_path)
|
|
242
|
+
|
|
243
|
+
# Generate stable TEST node ID from classname and name
|
|
244
|
+
test_id = f"test:{classname}::{name}" if classname else f"test::{name}"
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"id": f"{source_path}:{classname}::{name}",
|
|
248
|
+
"name": name,
|
|
249
|
+
"classname": classname,
|
|
250
|
+
"status": status,
|
|
251
|
+
"duration": duration,
|
|
252
|
+
"message": message,
|
|
253
|
+
"validates": validates,
|
|
254
|
+
"source_path": source_path,
|
|
255
|
+
"test_id": test_id,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
def _extract_req_ids(self, text: str, source_file: str | None = None) -> list[str]:
|
|
259
|
+
"""Extract requirement IDs from text.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
text: Text to search for requirement IDs.
|
|
263
|
+
source_file: Optional source file for file-specific config.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
List of normalized requirement IDs (using hyphens).
|
|
267
|
+
"""
|
|
268
|
+
pattern_config = self._get_pattern_config()
|
|
269
|
+
ref_config = self._get_reference_config(source_file)
|
|
270
|
+
|
|
271
|
+
# Use shared extraction function
|
|
272
|
+
ids = extract_ids_from_text(text, pattern_config, ref_config)
|
|
273
|
+
|
|
274
|
+
# Normalize: replace underscores with hyphens
|
|
275
|
+
normalized = []
|
|
276
|
+
for req_id in ids:
|
|
277
|
+
normalized_id = req_id.replace("_", "-")
|
|
278
|
+
if normalized_id not in normalized:
|
|
279
|
+
normalized.append(normalized_id)
|
|
280
|
+
|
|
281
|
+
return normalized
|
|
282
|
+
|
|
283
|
+
def can_parse(self, file_path: Path) -> bool:
|
|
284
|
+
"""Check if this parser can handle the given file.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
file_path: Path to the file.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
True for JSON files that look like pytest results.
|
|
291
|
+
"""
|
|
292
|
+
name = file_path.name.lower()
|
|
293
|
+
return file_path.suffix.lower() == ".json" and (
|
|
294
|
+
"pytest" in name or "test" in name or "result" in name
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def create_parser(
|
|
299
|
+
pattern_config: PatternConfig | None = None,
|
|
300
|
+
reference_resolver: ReferenceResolver | None = None,
|
|
301
|
+
base_path: Path | None = None,
|
|
302
|
+
) -> PytestJSONParser:
|
|
303
|
+
"""Factory function to create a PytestJSONParser.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
pattern_config: Optional configuration for ID structure.
|
|
307
|
+
reference_resolver: Optional resolver for file-specific configs.
|
|
308
|
+
base_path: Optional base path for resolving file paths.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
New PytestJSONParser instance.
|
|
312
|
+
"""
|
|
313
|
+
return PytestJSONParser(pattern_config, reference_resolver, base_path)
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""TestParser - Priority 80 parser for test references.
|
|
2
|
+
|
|
3
|
+
Parses test files for requirement references in test names and comments.
|
|
4
|
+
Uses the shared reference_config infrastructure for configurable patterns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Iterator
|
|
12
|
+
|
|
13
|
+
from elspais.graph.parsers import ParseContext, ParsedContent
|
|
14
|
+
from elspais.graph.parsers.config_helpers import is_empty_comment
|
|
15
|
+
from elspais.utilities.reference_config import (
|
|
16
|
+
ReferenceConfig,
|
|
17
|
+
ReferenceResolver,
|
|
18
|
+
_build_comment_prefix_pattern,
|
|
19
|
+
build_block_header_pattern,
|
|
20
|
+
build_block_ref_pattern,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from elspais.utilities.patterns import PatternConfig
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestParser:
|
|
28
|
+
"""Parser for test references.
|
|
29
|
+
|
|
30
|
+
Priority: 80 (after code references)
|
|
31
|
+
|
|
32
|
+
Recognizes:
|
|
33
|
+
- Test names with REQ references: test_foo_REQ_p00001
|
|
34
|
+
- Comments with REQ references: # Tests REQ-xxx
|
|
35
|
+
- Multiline block headers: -- TESTS REQUIREMENTS:
|
|
36
|
+
- Multiline block items: -- REQ-xxx: Description
|
|
37
|
+
|
|
38
|
+
Uses configurable patterns from ReferenceConfig for:
|
|
39
|
+
- Comment styles (# // -- etc.)
|
|
40
|
+
- Keywords (Tests, Validates, etc.)
|
|
41
|
+
- Separator characters (- _ etc.)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
priority = 80
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
pattern_config: PatternConfig | None = None,
|
|
49
|
+
reference_resolver: ReferenceResolver | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Initialize TestParser with optional configuration.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
pattern_config: Configuration for ID structure. If None, uses defaults.
|
|
55
|
+
reference_resolver: Resolver for file-specific reference config. If None,
|
|
56
|
+
uses default ReferenceConfig.
|
|
57
|
+
"""
|
|
58
|
+
self._pattern_config = pattern_config
|
|
59
|
+
self._reference_resolver = reference_resolver
|
|
60
|
+
|
|
61
|
+
def _get_pattern_config(self, context: ParseContext) -> PatternConfig:
|
|
62
|
+
"""Get pattern config from context or instance.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
context: Parse context that may contain pattern config.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
PatternConfig to use for parsing.
|
|
69
|
+
"""
|
|
70
|
+
if self._pattern_config is not None:
|
|
71
|
+
return self._pattern_config
|
|
72
|
+
|
|
73
|
+
if "pattern_config" in context.config:
|
|
74
|
+
return context.config["pattern_config"]
|
|
75
|
+
|
|
76
|
+
from elspais.utilities.patterns import PatternConfig
|
|
77
|
+
|
|
78
|
+
return PatternConfig.from_dict(
|
|
79
|
+
{
|
|
80
|
+
"prefix": "REQ",
|
|
81
|
+
"types": {
|
|
82
|
+
"prd": {"id": "p", "name": "PRD"},
|
|
83
|
+
"ops": {"id": "o", "name": "OPS"},
|
|
84
|
+
"dev": {"id": "d", "name": "DEV"},
|
|
85
|
+
},
|
|
86
|
+
"id_format": {"style": "numeric", "digits": 5},
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _get_reference_config(
|
|
91
|
+
self, context: ParseContext, pattern_config: PatternConfig
|
|
92
|
+
) -> ReferenceConfig:
|
|
93
|
+
"""Get reference config for the current file.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
context: Parse context with file path.
|
|
97
|
+
pattern_config: Pattern config (unused but available for consistency).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
ReferenceConfig for this file.
|
|
101
|
+
"""
|
|
102
|
+
if self._reference_resolver is not None:
|
|
103
|
+
file_path = Path(context.file_path)
|
|
104
|
+
repo_root = Path(context.config.get("repo_root", "."))
|
|
105
|
+
return self._reference_resolver.resolve(file_path, repo_root)
|
|
106
|
+
|
|
107
|
+
if "reference_resolver" in context.config:
|
|
108
|
+
resolver: ReferenceResolver = context.config["reference_resolver"]
|
|
109
|
+
file_path = Path(context.file_path)
|
|
110
|
+
repo_root = Path(context.config.get("repo_root", "."))
|
|
111
|
+
return resolver.resolve(file_path, repo_root)
|
|
112
|
+
|
|
113
|
+
return ReferenceConfig()
|
|
114
|
+
|
|
115
|
+
def _build_test_name_pattern(
|
|
116
|
+
self, pattern_config: PatternConfig, ref_config: ReferenceConfig
|
|
117
|
+
) -> re.Pattern[str]:
|
|
118
|
+
"""Build pattern for matching REQ references in test function names.
|
|
119
|
+
|
|
120
|
+
Test names use underscores: def test_foo_REQ_p00001_A
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
pattern_config: Configuration for ID structure.
|
|
124
|
+
ref_config: Configuration for reference matching.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Compiled regex pattern for matching test name references.
|
|
128
|
+
"""
|
|
129
|
+
prefix = pattern_config.prefix
|
|
130
|
+
|
|
131
|
+
# Get type codes
|
|
132
|
+
type_codes = pattern_config.get_all_type_ids()
|
|
133
|
+
if type_codes:
|
|
134
|
+
type_pattern = f"(?:{'|'.join(re.escape(t) for t in type_codes)})"
|
|
135
|
+
else:
|
|
136
|
+
type_pattern = r"[a-z]"
|
|
137
|
+
|
|
138
|
+
# Get ID format
|
|
139
|
+
id_format = pattern_config.id_format
|
|
140
|
+
style = id_format.get("style", "numeric")
|
|
141
|
+
digits = id_format.get("digits", 5)
|
|
142
|
+
|
|
143
|
+
if style == "numeric":
|
|
144
|
+
id_number_pattern = rf"\d{{{digits}}}"
|
|
145
|
+
else:
|
|
146
|
+
id_number_pattern = r"[A-Za-z0-9]+"
|
|
147
|
+
|
|
148
|
+
# Assertion pattern (uppercase letters, can be multiple like A_B_C)
|
|
149
|
+
# Add negative lookahead to prevent matching lowercase continuation
|
|
150
|
+
assertion_pattern = r"(?:_[A-Z](?![a-z]))+"
|
|
151
|
+
|
|
152
|
+
# Test names use underscores, so pattern uses _ for separators
|
|
153
|
+
full_pattern = (
|
|
154
|
+
rf"def\s+test_\w*"
|
|
155
|
+
rf"(?P<ref>{re.escape(prefix)}_{type_pattern}{id_number_pattern}"
|
|
156
|
+
rf"(?:{assertion_pattern})?)"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
flags = 0 if ref_config.case_sensitive else re.IGNORECASE
|
|
160
|
+
return re.compile(full_pattern, flags)
|
|
161
|
+
|
|
162
|
+
def _build_test_comment_pattern(
|
|
163
|
+
self, pattern_config: PatternConfig, ref_config: ReferenceConfig
|
|
164
|
+
) -> re.Pattern[str]:
|
|
165
|
+
"""Build pattern for matching REQ references in test comments.
|
|
166
|
+
|
|
167
|
+
Test comments use "Tests" keyword WITHOUT colon: # Tests REQ-xxx
|
|
168
|
+
This differs from CodeParser which uses "Validates:" WITH colon.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
pattern_config: Configuration for ID structure.
|
|
172
|
+
ref_config: Configuration for reference matching.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Compiled regex pattern for matching test comment references.
|
|
176
|
+
"""
|
|
177
|
+
# Build comment prefix pattern
|
|
178
|
+
comment_pattern = _build_comment_prefix_pattern(ref_config.comment_styles)
|
|
179
|
+
|
|
180
|
+
# Get validates keywords (includes "Tests")
|
|
181
|
+
keywords = ref_config.keywords.get("validates", ["Validates", "Tests"])
|
|
182
|
+
keyword_pattern = "|".join(re.escape(k) for k in keywords)
|
|
183
|
+
|
|
184
|
+
# Build ID pattern
|
|
185
|
+
prefix = pattern_config.prefix
|
|
186
|
+
sep_chars = "".join(re.escape(s) for s in ref_config.separators)
|
|
187
|
+
|
|
188
|
+
# Pattern for a single ID (may include assertion)
|
|
189
|
+
single_id = rf"{re.escape(prefix)}[{sep_chars}][A-Za-z0-9{sep_chars}]+"
|
|
190
|
+
|
|
191
|
+
# Full pattern: comment marker + keyword (NO colon) + space + refs
|
|
192
|
+
# This matches: # Tests REQ-xxx or # Test REQ-xxx
|
|
193
|
+
full_pattern = (
|
|
194
|
+
rf"{comment_pattern}\s*"
|
|
195
|
+
rf"(?:{keyword_pattern})s?\s+" # keyword with optional 's', space (NO colon)
|
|
196
|
+
rf"(?P<refs>{single_id}(?:\s*,?\s*{single_id})*)"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
flags = 0 if ref_config.case_sensitive else re.IGNORECASE
|
|
200
|
+
return re.compile(full_pattern, flags)
|
|
201
|
+
|
|
202
|
+
def claim_and_parse(
|
|
203
|
+
self,
|
|
204
|
+
lines: list[tuple[int, str]],
|
|
205
|
+
context: ParseContext,
|
|
206
|
+
) -> Iterator[ParsedContent]:
|
|
207
|
+
"""Claim and parse test references.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
lines: List of (line_number, content) tuples.
|
|
211
|
+
context: Parsing context.
|
|
212
|
+
|
|
213
|
+
Yields:
|
|
214
|
+
ParsedContent for each test reference.
|
|
215
|
+
"""
|
|
216
|
+
pattern_config = self._get_pattern_config(context)
|
|
217
|
+
ref_config = self._get_reference_config(context, pattern_config)
|
|
218
|
+
|
|
219
|
+
# Build patterns dynamically based on config
|
|
220
|
+
test_name_pattern = self._build_test_name_pattern(pattern_config, ref_config)
|
|
221
|
+
comment_pattern = self._build_test_comment_pattern(pattern_config, ref_config)
|
|
222
|
+
block_header_pattern = build_block_header_pattern(ref_config, "validates")
|
|
223
|
+
block_ref_pattern = build_block_ref_pattern(pattern_config, ref_config)
|
|
224
|
+
|
|
225
|
+
i = 0
|
|
226
|
+
while i < len(lines):
|
|
227
|
+
ln, text = lines[i]
|
|
228
|
+
validates: list[str] = []
|
|
229
|
+
|
|
230
|
+
# Check for REQ in test function name
|
|
231
|
+
name_match = test_name_pattern.search(text)
|
|
232
|
+
if name_match:
|
|
233
|
+
# Convert REQ_p00001 to REQ-p00001 and normalize prefix case
|
|
234
|
+
ref = name_match.group("ref").replace("_", "-")
|
|
235
|
+
# Ensure prefix is uppercase (e.g., req-d00001 -> REQ-d00001)
|
|
236
|
+
prefix = pattern_config.prefix
|
|
237
|
+
if ref.lower().startswith(prefix.lower() + "-"):
|
|
238
|
+
ref = prefix + ref[len(prefix) :]
|
|
239
|
+
validates.append(ref)
|
|
240
|
+
|
|
241
|
+
# Check for REQ in comment (single-line)
|
|
242
|
+
comment_match = comment_pattern.search(text)
|
|
243
|
+
if comment_match:
|
|
244
|
+
refs_str = comment_match.group("refs")
|
|
245
|
+
# Extract individual REQ IDs from the refs string
|
|
246
|
+
prefix = pattern_config.prefix
|
|
247
|
+
for ref_match in re.finditer(
|
|
248
|
+
rf"{re.escape(prefix)}[-_][A-Za-z0-9\-_]+", refs_str, re.IGNORECASE
|
|
249
|
+
):
|
|
250
|
+
ref = ref_match.group(0).replace("_", "-")
|
|
251
|
+
# Normalize prefix case (e.g., req-d00001 -> REQ-d00001)
|
|
252
|
+
if ref.lower().startswith(prefix.lower() + "-"):
|
|
253
|
+
ref = prefix + ref[len(prefix) :]
|
|
254
|
+
validates.append(ref)
|
|
255
|
+
|
|
256
|
+
if validates:
|
|
257
|
+
yield ParsedContent(
|
|
258
|
+
content_type="test_ref",
|
|
259
|
+
start_line=ln,
|
|
260
|
+
end_line=ln,
|
|
261
|
+
raw_text=text,
|
|
262
|
+
parsed_data={
|
|
263
|
+
"validates": validates,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
i += 1
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
# Check for multiline block header: -- TESTS REQUIREMENTS:
|
|
270
|
+
if block_header_pattern.search(text):
|
|
271
|
+
refs: list[str] = []
|
|
272
|
+
start_ln = ln
|
|
273
|
+
end_ln = ln
|
|
274
|
+
raw_lines = [text]
|
|
275
|
+
i += 1
|
|
276
|
+
|
|
277
|
+
# Collect REQ references from subsequent comment lines
|
|
278
|
+
while i < len(lines):
|
|
279
|
+
next_ln, next_text = lines[i]
|
|
280
|
+
ref_match = block_ref_pattern.match(next_text)
|
|
281
|
+
if ref_match:
|
|
282
|
+
refs.append(ref_match.group("ref"))
|
|
283
|
+
end_ln = next_ln
|
|
284
|
+
raw_lines.append(next_text)
|
|
285
|
+
i += 1
|
|
286
|
+
elif is_empty_comment(next_text, ref_config.comment_styles):
|
|
287
|
+
# Empty comment line, skip
|
|
288
|
+
i += 1
|
|
289
|
+
else:
|
|
290
|
+
# Non-comment line or different content, stop
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
if refs:
|
|
294
|
+
yield ParsedContent(
|
|
295
|
+
content_type="test_ref",
|
|
296
|
+
start_line=start_ln,
|
|
297
|
+
end_line=end_ln,
|
|
298
|
+
raw_text="\n".join(raw_lines),
|
|
299
|
+
parsed_data={
|
|
300
|
+
"validates": refs,
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
i += 1
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Relations - Edge types and relationship semantics.
|
|
2
|
+
|
|
3
|
+
This module defines the typed edges between graph nodes:
|
|
4
|
+
- EdgeKind: Enum of relationship types with semantic properties
|
|
5
|
+
- Edge: A typed edge between two nodes
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from elspais.graph.GraphNode import GraphNode
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EdgeKind(Enum):
|
|
19
|
+
"""Types of edges in the traceability graph.
|
|
20
|
+
|
|
21
|
+
Each edge type has semantic meaning for coverage calculation:
|
|
22
|
+
- IMPLEMENTS: Child claims to satisfy parent (coverage rollup)
|
|
23
|
+
- REFINES: Child adds detail to parent (NO coverage rollup)
|
|
24
|
+
- VALIDATES: Test validates requirement/assertion (coverage rollup)
|
|
25
|
+
- ADDRESSES: Links to user journey (informational, no coverage)
|
|
26
|
+
- CONTAINS: File structure containment (no coverage)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
IMPLEMENTS = "implements"
|
|
30
|
+
REFINES = "refines"
|
|
31
|
+
VALIDATES = "validates"
|
|
32
|
+
ADDRESSES = "addresses"
|
|
33
|
+
CONTAINS = "contains"
|
|
34
|
+
|
|
35
|
+
def contributes_to_coverage(self) -> bool:
|
|
36
|
+
"""Check if this edge type contributes to coverage rollup.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
True if edges of this type should be included in coverage
|
|
40
|
+
calculations, False otherwise.
|
|
41
|
+
"""
|
|
42
|
+
return self in (EdgeKind.IMPLEMENTS, EdgeKind.VALIDATES)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Edge:
|
|
47
|
+
"""A typed edge between two graph nodes.
|
|
48
|
+
|
|
49
|
+
Edges represent relationships with semantic meaning. The edge kind
|
|
50
|
+
determines how the relationship affects metrics like coverage.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
source: The parent/source node.
|
|
54
|
+
target: The child/target node.
|
|
55
|
+
kind: The type of relationship.
|
|
56
|
+
assertion_targets: For multi-assertion syntax, the specific
|
|
57
|
+
assertion labels targeted (e.g., ["A", "B", "C"]).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
source: GraphNode
|
|
61
|
+
target: GraphNode
|
|
62
|
+
kind: EdgeKind
|
|
63
|
+
assertion_targets: list[str] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
def __eq__(self, other: object) -> bool:
|
|
66
|
+
"""Check equality based on source, target, and kind."""
|
|
67
|
+
if not isinstance(other, Edge):
|
|
68
|
+
return NotImplemented
|
|
69
|
+
return (
|
|
70
|
+
self.source.id == other.source.id
|
|
71
|
+
and self.target.id == other.target.id
|
|
72
|
+
and self.kind == other.kind
|
|
73
|
+
and self.assertion_targets == other.assertion_targets
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def __hash__(self) -> int:
|
|
77
|
+
"""Hash based on source, target, and kind."""
|
|
78
|
+
return hash((self.source.id, self.target.id, self.kind.value))
|