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.
Files changed (147) hide show
  1. elspais/__init__.py +1 -10
  2. elspais/{sponsors/__init__.py → associates.py} +102 -56
  3. elspais/cli.py +366 -69
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +118 -169
  6. elspais/commands/changed.py +12 -23
  7. elspais/commands/config_cmd.py +10 -13
  8. elspais/commands/edit.py +33 -13
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +161 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -115
  13. elspais/commands/init.py +99 -22
  14. elspais/commands/reformat_cmd.py +41 -433
  15. elspais/commands/rules_cmd.py +2 -2
  16. elspais/commands/trace.py +443 -324
  17. elspais/commands/validate.py +193 -411
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -2
  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 +45 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +1998 -244
  61. elspais/testing/__init__.py +3 -3
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/scanner.py +301 -12
  65. elspais/utilities/__init__.py +1 -0
  66. elspais/utilities/docs_loader.py +115 -0
  67. elspais/utilities/git.py +607 -0
  68. elspais/{core → utilities}/hasher.py +8 -22
  69. elspais/utilities/md_renderer.py +189 -0
  70. elspais/{core → utilities}/patterns.py +56 -51
  71. elspais/utilities/reference_config.py +626 -0
  72. elspais/validation/__init__.py +19 -0
  73. elspais/validation/format.py +264 -0
  74. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  75. elspais-0.43.5.dist-info/RECORD +80 -0
  76. elspais/config/defaults.py +0 -179
  77. elspais/config/loader.py +0 -494
  78. elspais/core/__init__.py +0 -21
  79. elspais/core/git.py +0 -346
  80. elspais/core/models.py +0 -320
  81. elspais/core/parser.py +0 -639
  82. elspais/core/rules.py +0 -509
  83. elspais/mcp/context.py +0 -172
  84. elspais/mcp/serializers.py +0 -112
  85. elspais/reformat/__init__.py +0 -50
  86. elspais/reformat/detector.py +0 -112
  87. elspais/reformat/hierarchy.py +0 -247
  88. elspais/reformat/line_breaks.py +0 -218
  89. elspais/reformat/prompts.py +0 -133
  90. elspais/reformat/transformer.py +0 -266
  91. elspais/trace_view/__init__.py +0 -55
  92. elspais/trace_view/coverage.py +0 -183
  93. elspais/trace_view/generators/__init__.py +0 -12
  94. elspais/trace_view/generators/base.py +0 -334
  95. elspais/trace_view/generators/csv.py +0 -118
  96. elspais/trace_view/generators/markdown.py +0 -170
  97. elspais/trace_view/html/__init__.py +0 -33
  98. elspais/trace_view/html/generator.py +0 -1140
  99. elspais/trace_view/html/templates/base.html +0 -283
  100. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  101. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  102. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  103. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  104. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  105. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  106. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  107. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  108. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  109. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  110. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  111. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  112. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  113. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  114. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  115. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  116. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  117. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  118. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  119. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  120. elspais/trace_view/models.py +0 -378
  121. elspais/trace_view/review/__init__.py +0 -63
  122. elspais/trace_view/review/branches.py +0 -1142
  123. elspais/trace_view/review/models.py +0 -1200
  124. elspais/trace_view/review/position.py +0 -591
  125. elspais/trace_view/review/server.py +0 -1032
  126. elspais/trace_view/review/status.py +0 -455
  127. elspais/trace_view/review/storage.py +0 -1343
  128. elspais/trace_view/scanning.py +0 -213
  129. elspais/trace_view/specs/README.md +0 -84
  130. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  131. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  132. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  133. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  134. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  135. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  136. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  137. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  138. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  139. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  140. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  141. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  142. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  143. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  144. elspais-0.11.2.dist-info/RECORD +0 -101
  145. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  146. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  147. {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))