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
@@ -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
@@ -119,11 +125,7 @@ class PatternValidator:
119
125
  self._assertion_label_regex = re.compile(f"^{self.config.get_assertion_label_pattern()}$")
120
126
 
121
127
  def _build_regex(self, include_assertion: bool = False) -> re.Pattern:
122
- """Build regex pattern from configuration.
123
-
124
- Args:
125
- include_assertion: If True, include optional assertion suffix pattern
126
- """
128
+ """Build regex pattern from configuration."""
127
129
  template = self.config.id_template
128
130
 
129
131
  # Build type alternatives
@@ -165,11 +167,9 @@ class PatternValidator:
165
167
  associated_pattern = "(?P<associated>)"
166
168
 
167
169
  # Build full regex from template
168
- # Replace tokens with regex groups
169
170
  pattern = template
170
171
  pattern = pattern.replace("{prefix}", f"(?P<prefix>{re.escape(self.config.prefix)})")
171
172
 
172
- # Handle associated - it's optional
173
173
  if "{associated}" in pattern:
174
174
  pattern = pattern.replace("{associated}", f"(?:{associated_pattern})?")
175
175
  else:
@@ -182,7 +182,6 @@ class PatternValidator:
182
182
 
183
183
  pattern = pattern.replace("{id}", f"(?P<id>{id_pattern})")
184
184
 
185
- # Optionally add assertion suffix pattern
186
185
  if include_assertion:
187
186
  assertion_pattern = self.config.get_assertion_label_pattern()
188
187
  pattern = f"{pattern}(?:-(?P<assertion>{assertion_pattern}))?"
@@ -190,11 +189,10 @@ class PatternValidator:
190
189
  return re.compile(f"^{pattern}$")
191
190
 
192
191
  def parse(self, id_string: str, allow_assertion: bool = False) -> Optional[ParsedRequirement]:
193
- """
194
- Parse a requirement ID string into components.
192
+ """Parse a requirement ID string into components.
195
193
 
196
194
  Args:
197
- id_string: The requirement ID to parse (e.g., "REQ-p00001" or "REQ-p00001-A")
195
+ id_string: The requirement ID to parse
198
196
  allow_assertion: If True, allow and parse assertion suffix
199
197
 
200
198
  Returns:
@@ -216,8 +214,7 @@ class PatternValidator:
216
214
  )
217
215
 
218
216
  def is_valid(self, id_string: str, allow_assertion: bool = False) -> bool:
219
- """
220
- Check if an ID string is valid.
217
+ """Check if an ID string is valid.
221
218
 
222
219
  Args:
223
220
  id_string: The requirement ID to validate
@@ -229,11 +226,10 @@ class PatternValidator:
229
226
  return self.parse(id_string, allow_assertion=allow_assertion) is not None
230
227
 
231
228
  def is_valid_assertion_label(self, label: str) -> bool:
232
- """
233
- Check if an assertion label is valid.
229
+ """Check if an assertion label is valid.
234
230
 
235
231
  Args:
236
- label: The assertion label to validate (e.g., "A", "01")
232
+ label: The assertion label to validate
237
233
 
238
234
  Returns:
239
235
  True if valid, False otherwise
@@ -241,8 +237,7 @@ class PatternValidator:
241
237
  return self._assertion_label_regex.match(label) is not None
242
238
 
243
239
  def format_assertion_label(self, index: int) -> str:
244
- """
245
- Format an assertion label from a zero-based index.
240
+ """Format an assertion label from a zero-based index.
246
241
 
247
242
  Args:
248
243
  index: Zero-based index (0 = A or 00, 1 = B or 01, etc.)
@@ -277,11 +272,10 @@ class PatternValidator:
277
272
  return chr(ord("A") + index)
278
273
 
279
274
  def parse_assertion_label_index(self, label: str) -> int:
280
- """
281
- Parse an assertion label to get its zero-based index.
275
+ """Parse an assertion label to get its zero-based index.
282
276
 
283
277
  Args:
284
- label: The assertion label (e.g., "A", "01", "B")
278
+ label: The assertion label
285
279
 
286
280
  Returns:
287
281
  Zero-based index
@@ -305,11 +299,10 @@ class PatternValidator:
305
299
  raise ValueError(f"Cannot parse assertion label: {label}")
306
300
 
307
301
  def format(self, type_code: str, number: int, associated: Optional[str] = None) -> str:
308
- """
309
- Format a requirement ID from components.
302
+ """Format a requirement ID from components.
310
303
 
311
304
  Args:
312
- type_code: The requirement type code (e.g., "p")
305
+ type_code: The requirement type code
313
306
  number: The requirement number
314
307
  associated: Optional associated repo code
315
308
 
@@ -319,7 +312,6 @@ class PatternValidator:
319
312
  template = self.config.id_template
320
313
  id_format = self.config.id_format
321
314
 
322
- # Format number
323
315
  style = id_format.get("style", "numeric")
324
316
  if style == "numeric":
325
317
  digits = int(id_format.get("digits", 5))
@@ -331,11 +323,9 @@ class PatternValidator:
331
323
  else:
332
324
  formatted_number = str(number)
333
325
 
334
- # Build result
335
326
  result = template
336
327
  result = result.replace("{prefix}", self.config.prefix)
337
328
 
338
- # Handle associated
339
329
  if associated and "{associated}" in result:
340
330
  associated_config = self.config.associated or {}
341
331
  sep = associated_config.get("separator", "-")
@@ -349,14 +339,7 @@ class PatternValidator:
349
339
  return result
350
340
 
351
341
  def extract_implements_ids(self, implements_str: str) -> List[str]:
352
- """
353
- Extract requirement IDs from an Implements field value.
354
-
355
- Handles formats like:
356
- - "p00001"
357
- - "p00001, o00002"
358
- - "REQ-p00001, REQ-o00002"
359
- - "CAL-p00001"
342
+ """Extract requirement IDs from an Implements field value.
360
343
 
361
344
  Args:
362
345
  implements_str: The Implements field value
@@ -367,20 +350,42 @@ class PatternValidator:
367
350
  if not implements_str:
368
351
  return []
369
352
 
370
- # Split by comma
371
353
  parts = [p.strip() for p in implements_str.split(",")]
372
354
  result = []
373
355
 
374
356
  for part in parts:
375
357
  if not part:
376
358
  continue
377
-
378
- # Check if it's a full ID
379
359
  if self.is_valid(part):
380
360
  result.append(part)
381
361
  else:
382
- # It might be a shortened ID like "p00001" or "CAL-p00001"
383
- # Just keep the raw value for later resolution
384
362
  result.append(part)
385
363
 
386
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}"