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