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,626 @@
1
+ """Unified reference configuration for all parsers.
2
+
3
+ This module provides configurable reference pattern matching used by:
4
+ - CodeParser: # Implements: REQ-xxx
5
+ - TestParser: def test_REQ_xxx() and # Tests: REQ-xxx
6
+ - JUnitXMLParser: test names containing REQ-xxx
7
+ - PytestJSONParser: test names containing REQ-xxx
8
+
9
+ The configuration supports:
10
+ - Default patterns applied to all files
11
+ - File-type specific overrides (*.py, *.java, etc.)
12
+ - Directory-based overrides (tests/legacy/**)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import fnmatch
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ if TYPE_CHECKING:
24
+ from elspais.utilities.patterns import PatternConfig
25
+
26
+
27
+ @dataclass
28
+ class ReferenceConfig:
29
+ """Configuration for reference pattern matching.
30
+
31
+ Used by all parsers: TestParser, CodeParser, JUnitXMLParser, PytestJSONParser.
32
+
33
+ Attributes:
34
+ separators: Separator characters to accept between ID components (default: ["-", "_"])
35
+ case_sensitive: Whether matching is case-sensitive (default: False)
36
+ prefix_optional: Whether the prefix (e.g., "REQ") is required (default: False)
37
+ comment_styles: Recognized comment markers (default: ["#", "//", "--"])
38
+ keywords: Keywords for different reference types
39
+ """
40
+
41
+ separators: list[str] = field(default_factory=lambda: ["-", "_"])
42
+ case_sensitive: bool = False
43
+ prefix_optional: bool = False
44
+ comment_styles: list[str] = field(default_factory=lambda: ["#", "//", "--"])
45
+ keywords: dict[str, list[str]] = field(
46
+ default_factory=lambda: {
47
+ "implements": ["Implements", "IMPLEMENTS"],
48
+ "validates": ["Validates", "Tests", "VALIDATES", "TESTS"],
49
+ "refines": ["Refines", "REFINES"],
50
+ }
51
+ )
52
+
53
+ @classmethod
54
+ def from_dict(cls, data: dict[str, Any]) -> ReferenceConfig:
55
+ """Create ReferenceConfig from configuration dictionary.
56
+
57
+ Args:
58
+ data: Dictionary with optional keys: separators, case_sensitive,
59
+ prefix_optional, comment_styles, keywords
60
+
61
+ Returns:
62
+ ReferenceConfig instance with values from dict or defaults
63
+ """
64
+ return cls(
65
+ separators=data.get("separators", ["-", "_"]),
66
+ case_sensitive=data.get("case_sensitive", False),
67
+ prefix_optional=data.get("prefix_optional", False),
68
+ comment_styles=data.get("comment_styles", ["#", "//", "--"]),
69
+ keywords=data.get(
70
+ "keywords",
71
+ {
72
+ "implements": ["Implements", "IMPLEMENTS"],
73
+ "validates": ["Validates", "Tests", "VALIDATES", "TESTS"],
74
+ "refines": ["Refines", "REFINES"],
75
+ },
76
+ ),
77
+ )
78
+
79
+ def merge_with(self, override: ReferenceOverride) -> ReferenceConfig:
80
+ """Create a new config by merging this config with an override.
81
+
82
+ Only non-None values from the override are applied.
83
+
84
+ Args:
85
+ override: ReferenceOverride with values to apply
86
+
87
+ Returns:
88
+ New ReferenceConfig with merged values
89
+ """
90
+ # Start with current values
91
+ merged_keywords = dict(self.keywords)
92
+
93
+ # Merge keywords if override has them
94
+ if override.keywords is not None:
95
+ merged_keywords.update(override.keywords)
96
+
97
+ return ReferenceConfig(
98
+ separators=override.separators if override.separators is not None else self.separators,
99
+ case_sensitive=(
100
+ override.case_sensitive
101
+ if override.case_sensitive is not None
102
+ else self.case_sensitive
103
+ ),
104
+ prefix_optional=(
105
+ override.prefix_optional
106
+ if override.prefix_optional is not None
107
+ else self.prefix_optional
108
+ ),
109
+ comment_styles=(
110
+ override.comment_styles
111
+ if override.comment_styles is not None
112
+ else self.comment_styles
113
+ ),
114
+ keywords=merged_keywords,
115
+ )
116
+
117
+
118
+ @dataclass
119
+ class ReferenceOverride:
120
+ """Override rule for specific file types or directories.
121
+
122
+ Attributes:
123
+ match: Glob pattern to match files (e.g., "*.py", "tests/legacy/**")
124
+ separators: Override separator characters (None = use default)
125
+ case_sensitive: Override case sensitivity (None = use default)
126
+ prefix_optional: Override prefix requirement (None = use default)
127
+ comment_styles: Override comment styles (None = use default)
128
+ keywords: Override keywords dict (None = use default)
129
+ """
130
+
131
+ match: str
132
+ separators: list[str] | None = None
133
+ case_sensitive: bool | None = None
134
+ prefix_optional: bool | None = None
135
+ comment_styles: list[str] | None = None
136
+ keywords: dict[str, list[str]] | None = None
137
+
138
+ @classmethod
139
+ def from_dict(cls, data: dict[str, Any]) -> ReferenceOverride:
140
+ """Create ReferenceOverride from configuration dictionary.
141
+
142
+ Args:
143
+ data: Dictionary with 'match' (required) and optional override keys
144
+
145
+ Returns:
146
+ ReferenceOverride instance
147
+
148
+ Raises:
149
+ ValueError: If 'match' key is missing
150
+ """
151
+ if "match" not in data:
152
+ raise ValueError("ReferenceOverride requires 'match' pattern")
153
+
154
+ return cls(
155
+ match=data["match"],
156
+ separators=data.get("separators"),
157
+ case_sensitive=data.get("case_sensitive"),
158
+ prefix_optional=data.get("prefix_optional"),
159
+ comment_styles=data.get("comment_styles"),
160
+ keywords=data.get("keywords"),
161
+ )
162
+
163
+ def applies_to(self, file_path: Path, base_path: Path) -> bool:
164
+ """Check if this override applies to the given file.
165
+
166
+ Args:
167
+ file_path: Absolute path to the file being checked
168
+ base_path: Base path (repo root) for relative matching
169
+
170
+ Returns:
171
+ True if the match pattern matches the file
172
+ """
173
+ # Get relative path for matching
174
+ try:
175
+ rel_path = file_path.relative_to(base_path)
176
+ except ValueError:
177
+ # file_path is not under base_path, use filename only
178
+ rel_path = Path(file_path.name)
179
+
180
+ # Convert to string for fnmatch
181
+ rel_str = str(rel_path)
182
+
183
+ # Handle both forward and backward slashes on Windows
184
+ rel_str_normalized = rel_str.replace("\\", "/")
185
+
186
+ # Check if pattern matches
187
+ # fnmatch doesn't handle ** well, so we need special handling
188
+ pattern = self.match
189
+
190
+ if "**" in pattern:
191
+ # For ** patterns, we need recursive matching
192
+ # Split pattern into parts and match recursively
193
+ return self._match_recursive(rel_str_normalized, pattern)
194
+ else:
195
+ # Simple glob - check both full path and just filename
196
+ if fnmatch.fnmatch(rel_str_normalized, pattern):
197
+ return True
198
+ # Also try matching just the filename for patterns like "*.py"
199
+ if fnmatch.fnmatch(file_path.name, pattern):
200
+ return True
201
+ return False
202
+
203
+ def _match_recursive(self, path: str, pattern: str) -> bool:
204
+ """Match a path against a pattern containing **.
205
+
206
+ Args:
207
+ path: Normalized file path (forward slashes)
208
+ pattern: Glob pattern with ** for recursive matching
209
+
210
+ Returns:
211
+ True if pattern matches path
212
+ """
213
+ # Split on **
214
+ parts = pattern.split("**")
215
+
216
+ if len(parts) == 2:
217
+ # Pattern like "tests/**" or "**/test.py" or "tests/**/fixtures"
218
+ prefix, suffix = parts
219
+
220
+ # Remove leading/trailing slashes from parts
221
+ prefix = prefix.rstrip("/")
222
+ suffix = suffix.lstrip("/")
223
+
224
+ if prefix and suffix:
225
+ # Pattern like "tests/**/fixtures/*.py"
226
+ # Path must start with prefix and end matching suffix
227
+ if not path.startswith(prefix + "/") and path != prefix:
228
+ return False
229
+ # Get the part after prefix
230
+ remaining = path[len(prefix) :].lstrip("/")
231
+ # Check if any suffix of remaining matches the suffix pattern
232
+ parts_list = remaining.split("/")
233
+ for i in range(len(parts_list)):
234
+ candidate = "/".join(parts_list[i:])
235
+ if fnmatch.fnmatch(candidate, suffix):
236
+ return True
237
+ return False
238
+ elif prefix:
239
+ # Pattern like "tests/**" - match anything under tests/
240
+ return path.startswith(prefix + "/") or path == prefix
241
+ elif suffix:
242
+ # Pattern like "**/test.py" - match file anywhere
243
+ # Check if path ends with suffix or matches suffix directly
244
+ if fnmatch.fnmatch(path, suffix):
245
+ return True
246
+ # Check if any path component matches
247
+ for i in range(len(path.split("/"))):
248
+ candidate = "/".join(path.split("/")[i:])
249
+ if fnmatch.fnmatch(candidate, suffix):
250
+ return True
251
+ return False
252
+ else:
253
+ # Just "**" - matches everything
254
+ return True
255
+
256
+ # Multiple ** in pattern - complex case, fall back to basic matching
257
+ return fnmatch.fnmatch(path, pattern)
258
+
259
+
260
+ class ReferenceResolver:
261
+ """Resolves which reference config to use for a given file.
262
+
263
+ This is the SINGLE entry point for all parsers to get their configuration.
264
+ It applies defaults and matching overrides in order.
265
+
266
+ Example:
267
+ resolver = ReferenceResolver(defaults, overrides)
268
+ config = resolver.resolve(Path("tests/test_auth.py"), repo_root)
269
+ # config now has merged defaults + any matching overrides
270
+ """
271
+
272
+ def __init__(self, defaults: ReferenceConfig, overrides: list[ReferenceOverride] | None = None):
273
+ """Initialize the resolver.
274
+
275
+ Args:
276
+ defaults: Default configuration to use
277
+ overrides: Optional list of override rules (applied in order)
278
+ """
279
+ self.defaults = defaults
280
+ self.overrides = overrides or []
281
+
282
+ @classmethod
283
+ def from_config(cls, config: dict[str, Any]) -> ReferenceResolver:
284
+ """Create ReferenceResolver from the references config section.
285
+
286
+ Args:
287
+ config: The 'references' section from elspais config
288
+
289
+ Returns:
290
+ Configured ReferenceResolver
291
+ """
292
+ defaults_dict = config.get("defaults", {})
293
+ overrides_list = config.get("overrides", [])
294
+
295
+ defaults = ReferenceConfig.from_dict(defaults_dict)
296
+ overrides = [ReferenceOverride.from_dict(o) for o in overrides_list]
297
+
298
+ return cls(defaults, overrides)
299
+
300
+ def resolve(self, file_path: Path, base_path: Path) -> ReferenceConfig:
301
+ """Return merged config for file (defaults + matching overrides).
302
+
303
+ Overrides are applied in order, so later matching overrides
304
+ take precedence over earlier ones.
305
+
306
+ Args:
307
+ file_path: Path to the file being processed
308
+ base_path: Base path (repo root) for relative matching
309
+
310
+ Returns:
311
+ ReferenceConfig with all applicable overrides merged
312
+ """
313
+ result = self.defaults
314
+
315
+ for override in self.overrides:
316
+ if override.applies_to(file_path, base_path):
317
+ result = result.merge_with(override)
318
+
319
+ return result
320
+
321
+
322
+ # =============================================================================
323
+ # Pattern Builder Functions
324
+ # =============================================================================
325
+
326
+
327
+ def build_id_pattern(
328
+ pattern_config: PatternConfig,
329
+ ref_config: ReferenceConfig,
330
+ include_assertion: bool = True,
331
+ ) -> re.Pattern[str]:
332
+ """Build regex pattern for matching requirement IDs.
333
+
334
+ Creates a pattern that matches requirement IDs based on the configured
335
+ PatternConfig (for ID structure) and ReferenceConfig (for separators, etc.)
336
+
337
+ Args:
338
+ pattern_config: Configuration for ID structure (prefix, types, format)
339
+ ref_config: Configuration for reference matching (separators, case sensitivity)
340
+ include_assertion: Whether to include optional assertion suffix
341
+
342
+ Returns:
343
+ Compiled regex pattern for matching requirement IDs
344
+ """
345
+ # Get prefix from pattern_config
346
+ prefix = pattern_config.prefix
347
+
348
+ # Build separator pattern from ref_config
349
+ sep_pattern = _build_separator_pattern(ref_config.separators)
350
+
351
+ # Get type codes from pattern_config
352
+ type_codes = pattern_config.get_all_type_ids()
353
+ if type_codes:
354
+ type_pattern = f"(?:{'|'.join(re.escape(t) for t in type_codes)})"
355
+ else:
356
+ type_pattern = r"[a-z]" # Default: single lowercase letter
357
+
358
+ # Get ID format
359
+ id_format = pattern_config.id_format
360
+ style = id_format.get("style", "numeric")
361
+ digits = id_format.get("digits", 5)
362
+
363
+ if style == "numeric":
364
+ id_number_pattern = rf"\d{{{digits}}}"
365
+ else:
366
+ id_number_pattern = r"[A-Za-z0-9]+"
367
+
368
+ # Build assertion pattern if needed
369
+ assertion_pattern = ""
370
+ if include_assertion:
371
+ # Assertion labels are typically uppercase letters, possibly multiple
372
+ # IMPORTANT: Add negative lookahead (?![a-z]) to prevent matching
373
+ # lowercase letters that are part of longer words.
374
+ # e.g., test_REQ_p00001_login should NOT capture "l" as assertion
375
+ assertion_label = pattern_config.get_assertion_label_pattern()
376
+ if assertion_label:
377
+ # Make assertion optional with separator, with negative lookahead
378
+ assertion_pattern = rf"(?:{sep_pattern}(?P<assertion>{assertion_label})(?![a-z]))?"
379
+ else:
380
+ # Default: single uppercase letter, with negative lookahead
381
+ assertion_pattern = rf"(?:{sep_pattern}(?P<assertion>[A-Z])(?![a-z]))?"
382
+
383
+ # Build the full pattern
384
+ if ref_config.prefix_optional:
385
+ prefix_pattern = rf"(?:{re.escape(prefix)}{sep_pattern})?"
386
+ else:
387
+ prefix_pattern = rf"{re.escape(prefix)}{sep_pattern}"
388
+
389
+ full_pattern = (
390
+ rf"(?P<full_id>{prefix_pattern}"
391
+ rf"(?P<type>{type_pattern})"
392
+ rf"(?P<number>{id_number_pattern})"
393
+ rf"{assertion_pattern})"
394
+ )
395
+
396
+ flags = 0 if ref_config.case_sensitive else re.IGNORECASE
397
+ return re.compile(full_pattern, flags)
398
+
399
+
400
+ def build_comment_pattern(
401
+ pattern_config: PatternConfig,
402
+ ref_config: ReferenceConfig,
403
+ keyword_type: str = "implements",
404
+ ) -> re.Pattern[str]:
405
+ """Build pattern for matching reference comments.
406
+
407
+ Creates a pattern for single-line comments like:
408
+ - # Implements: REQ-p00001
409
+ - // Validates: REQ-p00002, REQ-p00003
410
+
411
+ Args:
412
+ pattern_config: Configuration for ID structure
413
+ ref_config: Configuration for comment styles and keywords
414
+ keyword_type: Type of keyword to match ("implements", "validates", "refines")
415
+
416
+ Returns:
417
+ Compiled regex pattern for matching reference comments
418
+ """
419
+ # Build comment prefix pattern
420
+ comment_pattern = _build_comment_prefix_pattern(ref_config.comment_styles)
421
+
422
+ # Get keywords for the type
423
+ keywords = ref_config.keywords.get(keyword_type, [keyword_type.capitalize()])
424
+ keyword_pattern = "|".join(re.escape(k) for k in keywords)
425
+
426
+ # Build ID pattern (simplified for comment matching - captures multiple)
427
+ prefix = pattern_config.prefix
428
+ sep_pattern = _build_separator_pattern(ref_config.separators)
429
+
430
+ # Pattern for a single ID (may include assertion)
431
+ single_id = (
432
+ rf"{re.escape(prefix)}{sep_pattern}[A-Za-z0-9{re.escape(''.join(ref_config.separators))}]+"
433
+ )
434
+
435
+ # Full pattern: comment marker + keyword: + refs
436
+ full_pattern = (
437
+ rf"{comment_pattern}\s*"
438
+ rf"(?:{keyword_pattern}):\s*"
439
+ rf"(?P<refs>{single_id}(?:\s*,\s*{single_id})*)"
440
+ )
441
+
442
+ flags = 0 if ref_config.case_sensitive else re.IGNORECASE
443
+ return re.compile(full_pattern, flags)
444
+
445
+
446
+ def build_block_header_pattern(
447
+ ref_config: ReferenceConfig,
448
+ keyword_type: str = "implements",
449
+ ) -> re.Pattern[str]:
450
+ """Build pattern for multi-line block headers.
451
+
452
+ Creates a pattern for block-style references like:
453
+ - # IMPLEMENTS REQUIREMENTS:
454
+ - // TESTS REQUIREMENTS:
455
+
456
+ Args:
457
+ ref_config: Configuration for comment styles and keywords
458
+ keyword_type: Type of keyword to match
459
+
460
+ Returns:
461
+ Compiled regex pattern for matching block headers
462
+ """
463
+ # Build comment prefix pattern
464
+ comment_pattern = _build_comment_prefix_pattern(ref_config.comment_styles)
465
+
466
+ # Get keywords and make variations
467
+ keywords = ref_config.keywords.get(keyword_type, [keyword_type.capitalize()])
468
+
469
+ # Add uppercase versions if not already present
470
+ all_keywords = set(keywords)
471
+ for k in keywords:
472
+ all_keywords.add(k.upper())
473
+
474
+ keyword_pattern = "|".join(re.escape(k) for k in all_keywords)
475
+
476
+ # Block header pattern
477
+ full_pattern = rf"{comment_pattern}\s*(?:{keyword_pattern})\s+REQUIREMENTS?:?\s*$"
478
+
479
+ return re.compile(full_pattern, re.IGNORECASE)
480
+
481
+
482
+ def build_block_ref_pattern(
483
+ pattern_config: PatternConfig,
484
+ ref_config: ReferenceConfig,
485
+ ) -> re.Pattern[str]:
486
+ """Build pattern for references within a block.
487
+
488
+ Creates a pattern for individual refs in a block like:
489
+ - # REQ-p00001
490
+ - // REQ-p00002-A
491
+
492
+ Args:
493
+ pattern_config: Configuration for ID structure
494
+ ref_config: Configuration for comment styles
495
+
496
+ Returns:
497
+ Compiled regex pattern for matching block references
498
+ """
499
+ # Build comment prefix pattern
500
+ comment_pattern = _build_comment_prefix_pattern(ref_config.comment_styles)
501
+
502
+ # Build ID pattern
503
+ prefix = pattern_config.prefix
504
+ sep_pattern = _build_separator_pattern(ref_config.separators)
505
+
506
+ # Pattern for ID (may include assertion)
507
+ id_pattern = (
508
+ rf"{re.escape(prefix)}{sep_pattern}[A-Za-z0-9{re.escape(''.join(ref_config.separators))}]+"
509
+ )
510
+
511
+ full_pattern = rf"^\s*{comment_pattern}\s+(?P<ref>{id_pattern})"
512
+
513
+ flags = 0 if ref_config.case_sensitive else re.IGNORECASE
514
+ return re.compile(full_pattern, flags)
515
+
516
+
517
+ def extract_ids_from_text(
518
+ text: str,
519
+ pattern_config: PatternConfig,
520
+ ref_config: ReferenceConfig,
521
+ ) -> list[str]:
522
+ """Extract all requirement/assertion IDs from text.
523
+
524
+ Finds all IDs matching the configured pattern in the given text.
525
+
526
+ Args:
527
+ text: Text to search for IDs
528
+ pattern_config: Configuration for ID structure
529
+ ref_config: Configuration for reference matching
530
+
531
+ Returns:
532
+ List of extracted ID strings (normalized)
533
+ """
534
+ pattern = build_id_pattern(pattern_config, ref_config, include_assertion=True)
535
+
536
+ ids = []
537
+ for match in pattern.finditer(text):
538
+ match.group("full_id")
539
+ normalized = normalize_extracted_id(match, pattern_config, ref_config)
540
+ if normalized:
541
+ ids.append(normalized)
542
+
543
+ return ids
544
+
545
+
546
+ def normalize_extracted_id(
547
+ match: re.Match[str],
548
+ pattern_config: PatternConfig,
549
+ ref_config: ReferenceConfig,
550
+ ) -> str:
551
+ """Normalize extracted ID to canonical format.
552
+
553
+ Converts matched ID to the standard format defined by pattern_config.
554
+
555
+ Args:
556
+ match: Regex match object with named groups
557
+ pattern_config: Configuration for ID structure
558
+ ref_config: Configuration for separators
559
+
560
+ Returns:
561
+ Normalized ID string in canonical format
562
+ """
563
+ # Get matched components
564
+ try:
565
+ type_code = match.group("type")
566
+ number = match.group("number")
567
+ except (IndexError, AttributeError):
568
+ # If groups don't exist, return the full match
569
+ return match.group(0)
570
+
571
+ # Build canonical ID using pattern_config
572
+ prefix = pattern_config.prefix
573
+ canonical_sep = "-" # Standard separator for canonical format
574
+
575
+ # Base ID
576
+ canonical = f"{prefix}{canonical_sep}{type_code}{number}"
577
+
578
+ # Add assertion if present
579
+ try:
580
+ assertion = match.group("assertion")
581
+ if assertion:
582
+ canonical = f"{canonical}{canonical_sep}{assertion.upper()}"
583
+ except (IndexError, AttributeError):
584
+ pass
585
+
586
+ return canonical
587
+
588
+
589
+ # =============================================================================
590
+ # Helper Functions
591
+ # =============================================================================
592
+
593
+
594
+ def _build_separator_pattern(separators: list[str]) -> str:
595
+ """Build regex pattern for matching any of the given separators.
596
+
597
+ Args:
598
+ separators: List of separator characters
599
+
600
+ Returns:
601
+ Regex pattern matching any separator
602
+ """
603
+ if not separators:
604
+ return "-"
605
+ if len(separators) == 1:
606
+ return re.escape(separators[0])
607
+ return f"[{''.join(re.escape(s) for s in separators)}]"
608
+
609
+
610
+ def _build_comment_prefix_pattern(comment_styles: list[str]) -> str:
611
+ """Build regex pattern for matching comment prefixes.
612
+
613
+ Args:
614
+ comment_styles: List of comment style markers
615
+
616
+ Returns:
617
+ Regex pattern matching any comment prefix
618
+ """
619
+ if not comment_styles:
620
+ return r"(?:#|//|--)"
621
+
622
+ patterns = []
623
+ for style in comment_styles:
624
+ patterns.append(re.escape(style))
625
+
626
+ return f"(?:{'|'.join(patterns)})"
@@ -0,0 +1,19 @@
1
+ """Validation module - Requirement format and content validation.
2
+
3
+ Provides validators for checking requirement format compliance
4
+ based on configurable rules.
5
+ """
6
+
7
+ from elspais.validation.format import (
8
+ FormatRulesConfig,
9
+ FormatViolation,
10
+ get_format_rules_config,
11
+ validate_requirement_format,
12
+ )
13
+
14
+ __all__ = [
15
+ "FormatRulesConfig",
16
+ "FormatViolation",
17
+ "validate_requirement_format",
18
+ "get_format_rules_config",
19
+ ]