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.
- elspais/__init__.py +1 -10
- elspais/{sponsors/__init__.py → associates.py} +102 -56
- elspais/cli.py +366 -69
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +118 -169
- elspais/commands/changed.py +12 -23
- elspais/commands/config_cmd.py +10 -13
- elspais/commands/edit.py +33 -13
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +161 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -115
- elspais/commands/init.py +99 -22
- elspais/commands/reformat_cmd.py +41 -433
- elspais/commands/rules_cmd.py +2 -2
- elspais/commands/trace.py +443 -324
- elspais/commands/validate.py +193 -411
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -2
- elspais/docs/cli/assertions.md +67 -0
- elspais/docs/cli/commands.md +304 -0
- elspais/docs/cli/config.md +262 -0
- elspais/docs/cli/format.md +66 -0
- elspais/docs/cli/git.md +45 -0
- elspais/docs/cli/health.md +190 -0
- elspais/docs/cli/hierarchy.md +60 -0
- elspais/docs/cli/ignore.md +72 -0
- elspais/docs/cli/mcp.md +245 -0
- elspais/docs/cli/quickstart.md +58 -0
- elspais/docs/cli/traceability.md +89 -0
- elspais/docs/cli/validation.md +96 -0
- elspais/graph/GraphNode.py +383 -0
- elspais/graph/__init__.py +40 -0
- elspais/graph/annotators.py +927 -0
- elspais/graph/builder.py +1886 -0
- elspais/graph/deserializer.py +248 -0
- elspais/graph/factory.py +284 -0
- elspais/graph/metrics.py +127 -0
- elspais/graph/mutations.py +161 -0
- elspais/graph/parsers/__init__.py +156 -0
- elspais/graph/parsers/code.py +213 -0
- elspais/graph/parsers/comments.py +112 -0
- elspais/graph/parsers/config_helpers.py +29 -0
- elspais/graph/parsers/heredocs.py +225 -0
- elspais/graph/parsers/journey.py +131 -0
- elspais/graph/parsers/remainder.py +79 -0
- elspais/graph/parsers/requirement.py +347 -0
- elspais/graph/parsers/results/__init__.py +6 -0
- elspais/graph/parsers/results/junit_xml.py +229 -0
- elspais/graph/parsers/results/pytest_json.py +313 -0
- elspais/graph/parsers/test.py +305 -0
- elspais/graph/relations.py +78 -0
- elspais/graph/serialize.py +216 -0
- elspais/html/__init__.py +8 -0
- elspais/html/generator.py +731 -0
- elspais/html/templates/trace_view.html.j2 +2151 -0
- elspais/mcp/__init__.py +45 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +1998 -244
- elspais/testing/__init__.py +3 -3
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/scanner.py +301 -12
- elspais/utilities/__init__.py +1 -0
- elspais/utilities/docs_loader.py +115 -0
- elspais/utilities/git.py +607 -0
- elspais/{core → utilities}/hasher.py +8 -22
- elspais/utilities/md_renderer.py +189 -0
- elspais/{core → utilities}/patterns.py +56 -51
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
- elspais-0.43.5.dist-info/RECORD +80 -0
- elspais/config/defaults.py +0 -179
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -346
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -639
- elspais/core/rules.py +0 -509
- elspais/mcp/context.py +0 -172
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -112
- elspais/reformat/hierarchy.py +0 -247
- elspais/reformat/line_breaks.py +0 -218
- elspais/reformat/prompts.py +0 -133
- elspais/reformat/transformer.py +0 -266
- elspais/trace_view/__init__.py +0 -55
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -334
- elspais/trace_view/generators/csv.py +0 -118
- elspais/trace_view/generators/markdown.py +0 -170
- elspais/trace_view/html/__init__.py +0 -33
- elspais/trace_view/html/generator.py +0 -1140
- elspais/trace_view/html/templates/base.html +0 -283
- elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
- elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
- elspais/trace_view/html/templates/components/legend_modal.html +0 -69
- elspais/trace_view/html/templates/components/review_panel.html +0 -118
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
- elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
- elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
- elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
- elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
- elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
- elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
- elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
- elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
- elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
- elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
- elspais/trace_view/html/templates/partials/scripts.js +0 -1741
- elspais/trace_view/html/templates/partials/styles.css +0 -1756
- elspais/trace_view/models.py +0 -378
- elspais/trace_view/review/__init__.py +0 -63
- elspais/trace_view/review/branches.py +0 -1142
- elspais/trace_view/review/models.py +0 -1200
- elspais/trace_view/review/position.py +0 -591
- elspais/trace_view/review/server.py +0 -1032
- elspais/trace_view/review/status.py +0 -455
- elspais/trace_view/review/storage.py +0 -1343
- elspais/trace_view/scanning.py +0 -213
- elspais/trace_view/specs/README.md +0 -84
- elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
- elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
- elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
- elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
- elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
- elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
- elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
- elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
- elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
- elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
- elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
- elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
- elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
- elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
- elspais-0.11.2.dist-info/RECORD +0 -101
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}"
|