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
@@ -1,7 +1,7 @@
1
- """
2
- elspais.core.hasher - Hash calculation for requirement change detection.
1
+ """Hasher - Hash calculation for requirement change detection.
3
2
 
4
3
  Provides functions for calculating and verifying SHA-256 based content hashes.
4
+ Ported from core/hasher.py.
5
5
  """
6
6
 
7
7
  import hashlib
@@ -10,20 +10,17 @@ from typing import Optional
10
10
 
11
11
 
12
12
  def clean_requirement_body(content: str, normalize_whitespace: bool = False) -> str:
13
- """
14
- Clean requirement body text for consistent hashing.
13
+ """Clean requirement body text for consistent hashing.
15
14
 
16
15
  Args:
17
16
  content: Raw requirement body text
18
17
  normalize_whitespace: If True, aggressively normalize whitespace.
19
- If False (default), only remove trailing blank lines
20
- (matches hht-diary tools behavior).
18
+ If False (default), only remove trailing blank lines.
21
19
 
22
20
  Returns:
23
21
  Cleaned text suitable for hashing
24
22
  """
25
23
  if normalize_whitespace:
26
- # Aggressive normalization mode
27
24
  lines = content.split("\n")
28
25
 
29
26
  # Remove leading blank lines
@@ -49,13 +46,10 @@ def clean_requirement_body(content: str, normalize_whitespace: bool = False) ->
49
46
 
50
47
  return "\n".join(result_lines)
51
48
  else:
52
- # Default: hht-diary compatible mode (only remove trailing blank lines)
49
+ # Default: only remove trailing blank lines
53
50
  lines = content.split("\n")
54
-
55
- # Remove trailing blank lines (matches hht-diary behavior)
56
51
  while lines and not lines[-1].strip():
57
52
  lines.pop()
58
-
59
53
  return "\n".join(lines)
60
54
 
61
55
 
@@ -65,23 +59,19 @@ def calculate_hash(
65
59
  algorithm: str = "sha256",
66
60
  normalize_whitespace: bool = False,
67
61
  ) -> str:
68
- """
69
- Calculate a content hash for change detection.
62
+ """Calculate a content hash for change detection.
70
63
 
71
64
  Args:
72
65
  content: Text content to hash
73
66
  length: Number of characters in the hash (default 8)
74
67
  algorithm: Hash algorithm to use (default "sha256")
75
68
  normalize_whitespace: If True, aggressively normalize whitespace.
76
- If False (default), only remove trailing blank lines.
77
69
 
78
70
  Returns:
79
71
  Hexadecimal hash string of specified length
80
72
  """
81
- # Clean the content first
82
73
  cleaned = clean_requirement_body(content, normalize_whitespace=normalize_whitespace)
83
74
 
84
- # Calculate hash
85
75
  if algorithm == "sha256":
86
76
  hash_obj = hashlib.sha256(cleaned.encode("utf-8"))
87
77
  elif algorithm == "sha1":
@@ -91,7 +81,6 @@ def calculate_hash(
91
81
  else:
92
82
  raise ValueError(f"Unsupported hash algorithm: {algorithm}")
93
83
 
94
- # Return first `length` characters of hex digest
95
84
  return hash_obj.hexdigest()[:length]
96
85
 
97
86
 
@@ -102,8 +91,7 @@ def verify_hash(
102
91
  algorithm: str = "sha256",
103
92
  normalize_whitespace: bool = False,
104
93
  ) -> bool:
105
- """
106
- Verify that content matches an expected hash.
94
+ """Verify that content matches an expected hash.
107
95
 
108
96
  Args:
109
97
  content: Text content to verify
@@ -111,7 +99,6 @@ def verify_hash(
111
99
  length: Hash length used (default 8)
112
100
  algorithm: Hash algorithm used (default "sha256")
113
101
  normalize_whitespace: If True, aggressively normalize whitespace.
114
- If False (default), only remove trailing blank lines.
115
102
 
116
103
  Returns:
117
104
  True if hash matches, False otherwise
@@ -126,8 +113,7 @@ def verify_hash(
126
113
 
127
114
 
128
115
  def extract_hash_from_footer(footer_text: str) -> Optional[str]:
129
- """
130
- Extract hash value from requirement footer line.
116
+ """Extract hash value from requirement footer line.
131
117
 
132
118
  Looks for pattern: **Hash**: XXXXXXXX
133
119
 
@@ -0,0 +1,189 @@
1
+ """Markdown-to-ANSI renderer for terminal documentation display.
2
+
3
+ Renders markdown files with ANSI color codes for terminal display.
4
+ Supports headings, code blocks, bold text, inline code, and command hints.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import sys
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ pass
15
+
16
+
17
+ class MarkdownRenderer:
18
+ """Renders markdown content to ANSI-colored terminal output."""
19
+
20
+ # ANSI escape codes
21
+ BOLD = "\033[1m"
22
+ DIM = "\033[2m"
23
+ CYAN = "\033[36m"
24
+ GREEN = "\033[32m"
25
+ YELLOW = "\033[33m"
26
+ RESET = "\033[0m"
27
+
28
+ def __init__(self, use_color: bool = True) -> None:
29
+ """Initialize renderer with color settings.
30
+
31
+ Args:
32
+ use_color: Whether to emit ANSI color codes.
33
+ """
34
+ self.use_color = use_color
35
+
36
+ def _c(self, code: str) -> str:
37
+ """Return ANSI code if colors enabled, else empty string."""
38
+ return code if self.use_color else ""
39
+
40
+ def render(self, markdown: str) -> str:
41
+ """Render markdown content to ANSI-colored output.
42
+
43
+ Args:
44
+ markdown: Raw markdown text.
45
+
46
+ Returns:
47
+ Formatted text with ANSI codes (if colors enabled).
48
+ """
49
+ lines = markdown.split("\n")
50
+ output_lines: list[str] = []
51
+ in_code_block = False
52
+ code_lines: list[str] = []
53
+
54
+ for line in lines:
55
+ # Handle code block boundaries
56
+ if line.strip().startswith("```"):
57
+ if in_code_block:
58
+ # End code block - emit collected lines
59
+ output_lines.extend(self._render_code_block(code_lines))
60
+ code_lines = []
61
+ in_code_block = False
62
+ else:
63
+ # Start code block
64
+ in_code_block = True
65
+ continue
66
+
67
+ if in_code_block:
68
+ code_lines.append(line)
69
+ continue
70
+
71
+ # Process regular lines
72
+ output_lines.append(self._render_line(line))
73
+
74
+ # Handle unclosed code block
75
+ if code_lines:
76
+ output_lines.extend(self._render_code_block(code_lines))
77
+
78
+ return "\n".join(output_lines)
79
+
80
+ def _render_code_block(self, lines: list[str]) -> list[str]:
81
+ """Render a fenced code block with dim formatting."""
82
+ result = []
83
+ for line in lines:
84
+ # Indent code blocks by 2 spaces and dim them
85
+ result.append(f" {self._c(self.DIM)}{line}{self._c(self.RESET)}")
86
+ return result
87
+
88
+ def _render_line(self, line: str) -> str:
89
+ """Render a single line of markdown."""
90
+ stripped = line.strip()
91
+
92
+ # Level 1 heading: # Title -> boxed heading
93
+ if stripped.startswith("# ") and not stripped.startswith("## "):
94
+ title = stripped[2:].strip()
95
+ return self._render_heading(title)
96
+
97
+ # Level 2 heading: ## Subheading -> green with underline
98
+ if stripped.startswith("## "):
99
+ title = stripped[3:].strip()
100
+ return self._render_subheading(title)
101
+
102
+ # Level 3+ headings: just bold
103
+ if stripped.startswith("### "):
104
+ title = stripped[4:].strip()
105
+ return f"\n{self._c(self.BOLD)}{title}{self._c(self.RESET)}\n"
106
+
107
+ # Regular line - apply inline formatting
108
+ return self._render_inline(line)
109
+
110
+ def _render_heading(self, title: str) -> str:
111
+ """Render a level-1 heading with box borders."""
112
+ border = "═" * 60
113
+ return (
114
+ f"\n{self._c(self.BOLD)}{self._c(self.CYAN)}{border}{self._c(self.RESET)}\n"
115
+ f"{self._c(self.BOLD)}{title}{self._c(self.RESET)}\n"
116
+ f"{self._c(self.BOLD)}{self._c(self.CYAN)}{border}{self._c(self.RESET)}\n"
117
+ )
118
+
119
+ def _render_subheading(self, title: str) -> str:
120
+ """Render a level-2 heading with underline."""
121
+ underline = "─" * 40
122
+ return (
123
+ f"\n{self._c(self.BOLD)}{self._c(self.GREEN)}{title}{self._c(self.RESET)}\n"
124
+ f"{self._c(self.DIM)}{underline}{self._c(self.RESET)}\n"
125
+ )
126
+
127
+ def _render_inline(self, text: str) -> str:
128
+ """Apply inline markdown formatting to text.
129
+
130
+ Handles:
131
+ - **bold** -> BOLD
132
+ - `code` -> CYAN
133
+ - $ command -> GREEN $ prefix with dim comment
134
+ """
135
+ result = text
136
+
137
+ # Handle command lines: "$ command # comment"
138
+ # Match lines that have "$ " near the start (possibly indented)
139
+ cmd_match = re.match(r"^(\s*)(\$)\s+(.*)$", result)
140
+ if cmd_match:
141
+ indent, dollar, rest = cmd_match.groups()
142
+ # Split command from comment
143
+ if " #" in rest or "\t#" in rest:
144
+ # Find the comment part
145
+ comment_match = re.search(r"(\s{2,}#.*)$", rest)
146
+ if comment_match:
147
+ comment = comment_match.group(1)
148
+ cmd_part = rest[: comment_match.start()]
149
+ result = (
150
+ f"{indent}{self._c(self.GREEN)}{dollar}{self._c(self.RESET)} "
151
+ f"{cmd_part}{self._c(self.DIM)}{comment}{self._c(self.RESET)}"
152
+ )
153
+ else:
154
+ result = f"{indent}{self._c(self.GREEN)}{dollar}{self._c(self.RESET)} {rest}"
155
+ else:
156
+ result = f"{indent}{self._c(self.GREEN)}{dollar}{self._c(self.RESET)} {rest}"
157
+ return result
158
+
159
+ # Bold: **text** -> BOLD text RESET
160
+ result = re.sub(
161
+ r"\*\*([^*]+)\*\*",
162
+ lambda m: f"{self._c(self.BOLD)}{m.group(1)}{self._c(self.RESET)}",
163
+ result,
164
+ )
165
+
166
+ # Inline code: `text` -> CYAN text RESET
167
+ result = re.sub(
168
+ r"`([^`]+)`",
169
+ lambda m: f"{self._c(self.CYAN)}{m.group(1)}{self._c(self.RESET)}",
170
+ result,
171
+ )
172
+
173
+ return result
174
+
175
+
176
+ def render_markdown(markdown: str, use_color: bool | None = None) -> str:
177
+ """Convenience function to render markdown to ANSI.
178
+
179
+ Args:
180
+ markdown: Raw markdown text.
181
+ use_color: Whether to use ANSI colors. If None, auto-detect TTY.
182
+
183
+ Returns:
184
+ Formatted text suitable for terminal output.
185
+ """
186
+ if use_color is None:
187
+ use_color = sys.stdout.isatty()
188
+ renderer = MarkdownRenderer(use_color=use_color)
189
+ return renderer.render(markdown)
@@ -1,24 +1,34 @@
1
- """
2
- elspais.core.patterns - Configurable requirement ID pattern matching.
1
+ """Patterns - Configurable requirement ID pattern matching.
3
2
 
4
3
  Supports multiple ID formats:
5
4
  - HHT style: REQ-p00001, REQ-CAL-d00001
6
5
  - Type-prefix style: PRD-00001, OPS-00001, DEV-00001
7
6
  - Jira style: PROJ-123
8
7
  - Named: REQ-UserAuth
8
+
9
+ Ported from core/patterns.py.
9
10
  """
10
11
 
11
12
  import re
12
13
  from dataclasses import dataclass
13
14
  from typing import Any, Dict, List, Optional
14
15
 
15
- from elspais.core.models import ParsedRequirement
16
+
17
+ @dataclass
18
+ class ParsedRequirement:
19
+ """Result of parsing a requirement ID string."""
20
+
21
+ full_id: str
22
+ prefix: str
23
+ associated: Optional[str]
24
+ type_code: str
25
+ number: str
26
+ assertion: Optional[str] = None
16
27
 
17
28
 
18
29
  @dataclass
19
30
  class PatternConfig:
20
- """
21
- Configuration for requirement ID patterns.
31
+ """Configuration for requirement ID patterns.
22
32
 
23
33
  Attributes:
24
34
  id_template: Template string with tokens {prefix}, {associated}, {type}, {id}
@@ -89,7 +99,6 @@ class PatternConfig:
89
99
  if max_count is not None:
90
100
  return int(max_count)
91
101
 
92
- # Default max based on style
93
102
  if style == "uppercase":
94
103
  return 26
95
104
  elif style == "numeric":
@@ -102,13 +111,10 @@ class PatternConfig:
102
111
 
103
112
 
104
113
  class PatternValidator:
105
- """
106
- Validates and parses requirement IDs against configured patterns.
107
- """
114
+ """Validates and parses requirement IDs against configured patterns."""
108
115
 
109
116
  def __init__(self, config: PatternConfig):
110
- """
111
- Initialize pattern validator.
117
+ """Initialize pattern validator.
112
118
 
113
119
  Args:
114
120
  config: Pattern configuration
@@ -116,16 +122,10 @@ class PatternValidator:
116
122
  self.config = config
117
123
  self._regex = self._build_regex()
118
124
  self._regex_with_assertion = self._build_regex(include_assertion=True)
119
- self._assertion_label_regex = re.compile(
120
- f"^{self.config.get_assertion_label_pattern()}$"
121
- )
125
+ self._assertion_label_regex = re.compile(f"^{self.config.get_assertion_label_pattern()}$")
122
126
 
123
127
  def _build_regex(self, include_assertion: bool = False) -> re.Pattern:
124
- """Build regex pattern from configuration.
125
-
126
- Args:
127
- include_assertion: If True, include optional assertion suffix pattern
128
- """
128
+ """Build regex pattern from configuration."""
129
129
  template = self.config.id_template
130
130
 
131
131
  # Build type alternatives
@@ -167,11 +167,9 @@ class PatternValidator:
167
167
  associated_pattern = "(?P<associated>)"
168
168
 
169
169
  # Build full regex from template
170
- # Replace tokens with regex groups
171
170
  pattern = template
172
171
  pattern = pattern.replace("{prefix}", f"(?P<prefix>{re.escape(self.config.prefix)})")
173
172
 
174
- # Handle associated - it's optional
175
173
  if "{associated}" in pattern:
176
174
  pattern = pattern.replace("{associated}", f"(?:{associated_pattern})?")
177
175
  else:
@@ -184,7 +182,6 @@ class PatternValidator:
184
182
 
185
183
  pattern = pattern.replace("{id}", f"(?P<id>{id_pattern})")
186
184
 
187
- # Optionally add assertion suffix pattern
188
185
  if include_assertion:
189
186
  assertion_pattern = self.config.get_assertion_label_pattern()
190
187
  pattern = f"{pattern}(?:-(?P<assertion>{assertion_pattern}))?"
@@ -192,11 +189,10 @@ class PatternValidator:
192
189
  return re.compile(f"^{pattern}$")
193
190
 
194
191
  def parse(self, id_string: str, allow_assertion: bool = False) -> Optional[ParsedRequirement]:
195
- """
196
- Parse a requirement ID string into components.
192
+ """Parse a requirement ID string into components.
197
193
 
198
194
  Args:
199
- id_string: The requirement ID to parse (e.g., "REQ-p00001" or "REQ-p00001-A")
195
+ id_string: The requirement ID to parse
200
196
  allow_assertion: If True, allow and parse assertion suffix
201
197
 
202
198
  Returns:
@@ -218,8 +214,7 @@ class PatternValidator:
218
214
  )
219
215
 
220
216
  def is_valid(self, id_string: str, allow_assertion: bool = False) -> bool:
221
- """
222
- Check if an ID string is valid.
217
+ """Check if an ID string is valid.
223
218
 
224
219
  Args:
225
220
  id_string: The requirement ID to validate
@@ -231,11 +226,10 @@ class PatternValidator:
231
226
  return self.parse(id_string, allow_assertion=allow_assertion) is not None
232
227
 
233
228
  def is_valid_assertion_label(self, label: str) -> bool:
234
- """
235
- Check if an assertion label is valid.
229
+ """Check if an assertion label is valid.
236
230
 
237
231
  Args:
238
- label: The assertion label to validate (e.g., "A", "01")
232
+ label: The assertion label to validate
239
233
 
240
234
  Returns:
241
235
  True if valid, False otherwise
@@ -243,8 +237,7 @@ class PatternValidator:
243
237
  return self._assertion_label_regex.match(label) is not None
244
238
 
245
239
  def format_assertion_label(self, index: int) -> str:
246
- """
247
- Format an assertion label from a zero-based index.
240
+ """Format an assertion label from a zero-based index.
248
241
 
249
242
  Args:
250
243
  index: Zero-based index (0 = A or 00, 1 = B or 01, etc.)
@@ -279,11 +272,10 @@ class PatternValidator:
279
272
  return chr(ord("A") + index)
280
273
 
281
274
  def parse_assertion_label_index(self, label: str) -> int:
282
- """
283
- Parse an assertion label to get its zero-based index.
275
+ """Parse an assertion label to get its zero-based index.
284
276
 
285
277
  Args:
286
- label: The assertion label (e.g., "A", "01", "B")
278
+ label: The assertion label
287
279
 
288
280
  Returns:
289
281
  Zero-based index
@@ -306,14 +298,11 @@ class PatternValidator:
306
298
 
307
299
  raise ValueError(f"Cannot parse assertion label: {label}")
308
300
 
309
- def format(
310
- self, type_code: str, number: int, associated: Optional[str] = None
311
- ) -> str:
312
- """
313
- Format a requirement ID from components.
301
+ def format(self, type_code: str, number: int, associated: Optional[str] = None) -> str:
302
+ """Format a requirement ID from components.
314
303
 
315
304
  Args:
316
- type_code: The requirement type code (e.g., "p")
305
+ type_code: The requirement type code
317
306
  number: The requirement number
318
307
  associated: Optional associated repo code
319
308
 
@@ -323,7 +312,6 @@ class PatternValidator:
323
312
  template = self.config.id_template
324
313
  id_format = self.config.id_format
325
314
 
326
- # Format number
327
315
  style = id_format.get("style", "numeric")
328
316
  if style == "numeric":
329
317
  digits = int(id_format.get("digits", 5))
@@ -335,11 +323,9 @@ class PatternValidator:
335
323
  else:
336
324
  formatted_number = str(number)
337
325
 
338
- # Build result
339
326
  result = template
340
327
  result = result.replace("{prefix}", self.config.prefix)
341
328
 
342
- # Handle associated
343
329
  if associated and "{associated}" in result:
344
330
  associated_config = self.config.associated or {}
345
331
  sep = associated_config.get("separator", "-")
@@ -353,14 +339,7 @@ class PatternValidator:
353
339
  return result
354
340
 
355
341
  def extract_implements_ids(self, implements_str: str) -> List[str]:
356
- """
357
- Extract requirement IDs from an Implements field value.
358
-
359
- Handles formats like:
360
- - "p00001"
361
- - "p00001, o00002"
362
- - "REQ-p00001, REQ-o00002"
363
- - "CAL-p00001"
342
+ """Extract requirement IDs from an Implements field value.
364
343
 
365
344
  Args:
366
345
  implements_str: The Implements field value
@@ -371,20 +350,42 @@ class PatternValidator:
371
350
  if not implements_str:
372
351
  return []
373
352
 
374
- # Split by comma
375
353
  parts = [p.strip() for p in implements_str.split(",")]
376
354
  result = []
377
355
 
378
356
  for part in parts:
379
357
  if not part:
380
358
  continue
381
-
382
- # Check if it's a full ID
383
359
  if self.is_valid(part):
384
360
  result.append(part)
385
361
  else:
386
- # It might be a shortened ID like "p00001" or "CAL-p00001"
387
- # Just keep the raw value for later resolution
388
362
  result.append(part)
389
363
 
390
364
  return result
365
+
366
+
367
+ def normalize_req_id(
368
+ req_id: str,
369
+ prefix_or_validator: str | PatternValidator = "REQ",
370
+ ) -> str:
371
+ """Normalize ID to canonical format.
372
+
373
+ Examples:
374
+ 'd00027' -> 'REQ-d00027'
375
+ 'REQ-d00027' -> 'REQ-d00027'
376
+
377
+ Args:
378
+ req_id: The requirement ID to normalize
379
+ prefix_or_validator: Either a prefix string or a PatternValidator instance
380
+
381
+ Returns:
382
+ Normalized ID with prefix
383
+ """
384
+ if isinstance(prefix_or_validator, PatternValidator):
385
+ prefix = prefix_or_validator.config.prefix
386
+ else:
387
+ prefix = prefix_or_validator
388
+
389
+ if req_id.startswith(f"{prefix}-"):
390
+ return req_id
391
+ return f"{prefix}-{req_id}"