elspais 0.11.1__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.
Files changed (148) hide show
  1. elspais/__init__.py +2 -11
  2. elspais/{sponsors/__init__.py → associates.py} +102 -58
  3. elspais/cli.py +395 -79
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +121 -173
  6. elspais/commands/changed.py +15 -30
  7. elspais/commands/config_cmd.py +13 -16
  8. elspais/commands/edit.py +60 -44
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +167 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -114
  13. elspais/commands/init.py +103 -26
  14. elspais/commands/reformat_cmd.py +41 -444
  15. elspais/commands/rules_cmd.py +7 -3
  16. elspais/commands/trace.py +444 -321
  17. elspais/commands/validate.py +195 -415
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -3
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +47 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +2016 -247
  61. elspais/testing/__init__.py +4 -4
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/result_parser.py +25 -21
  65. elspais/testing/scanner.py +301 -12
  66. elspais/utilities/__init__.py +1 -0
  67. elspais/utilities/docs_loader.py +115 -0
  68. elspais/utilities/git.py +607 -0
  69. elspais/{core → utilities}/hasher.py +8 -22
  70. elspais/utilities/md_renderer.py +189 -0
  71. elspais/{core → utilities}/patterns.py +58 -57
  72. elspais/utilities/reference_config.py +626 -0
  73. elspais/validation/__init__.py +19 -0
  74. elspais/validation/format.py +264 -0
  75. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  76. elspais-0.43.5.dist-info/RECORD +80 -0
  77. elspais/config/defaults.py +0 -173
  78. elspais/config/loader.py +0 -494
  79. elspais/core/__init__.py +0 -21
  80. elspais/core/git.py +0 -352
  81. elspais/core/models.py +0 -320
  82. elspais/core/parser.py +0 -640
  83. elspais/core/rules.py +0 -514
  84. elspais/mcp/context.py +0 -171
  85. elspais/mcp/serializers.py +0 -112
  86. elspais/reformat/__init__.py +0 -50
  87. elspais/reformat/detector.py +0 -119
  88. elspais/reformat/hierarchy.py +0 -246
  89. elspais/reformat/line_breaks.py +0 -220
  90. elspais/reformat/prompts.py +0 -123
  91. elspais/reformat/transformer.py +0 -264
  92. elspais/trace_view/__init__.py +0 -54
  93. elspais/trace_view/coverage.py +0 -183
  94. elspais/trace_view/generators/__init__.py +0 -12
  95. elspais/trace_view/generators/base.py +0 -329
  96. elspais/trace_view/generators/csv.py +0 -122
  97. elspais/trace_view/generators/markdown.py +0 -175
  98. elspais/trace_view/html/__init__.py +0 -31
  99. elspais/trace_view/html/generator.py +0 -1006
  100. elspais/trace_view/html/templates/base.html +0 -283
  101. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  102. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  103. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  104. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  105. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  106. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  107. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  108. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  109. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  110. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  111. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  112. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  113. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  114. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  115. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  116. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  117. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  118. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  119. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  120. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  121. elspais/trace_view/models.py +0 -353
  122. elspais/trace_view/review/__init__.py +0 -60
  123. elspais/trace_view/review/branches.py +0 -1149
  124. elspais/trace_view/review/models.py +0 -1205
  125. elspais/trace_view/review/position.py +0 -609
  126. elspais/trace_view/review/server.py +0 -1056
  127. elspais/trace_view/review/status.py +0 -470
  128. elspais/trace_view/review/storage.py +0 -1367
  129. elspais/trace_view/scanning.py +0 -213
  130. elspais/trace_view/specs/README.md +0 -84
  131. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  132. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  133. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  134. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  135. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  136. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  137. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  138. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  139. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  140. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  141. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  142. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  143. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  144. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  145. elspais-0.11.1.dist-info/RECORD +0 -101
  146. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  147. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  148. {elspais-0.11.1.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))