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.
- elspais/__init__.py +2 -11
- elspais/{sponsors/__init__.py → associates.py} +102 -58
- elspais/cli.py +395 -79
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +121 -173
- elspais/commands/changed.py +15 -30
- elspais/commands/config_cmd.py +13 -16
- elspais/commands/edit.py +60 -44
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +167 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -114
- elspais/commands/init.py +103 -26
- elspais/commands/reformat_cmd.py +41 -444
- elspais/commands/rules_cmd.py +7 -3
- elspais/commands/trace.py +444 -321
- elspais/commands/validate.py +195 -415
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -3
- 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 +47 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +2016 -247
- elspais/testing/__init__.py +4 -4
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/result_parser.py +25 -21
- 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 +58 -57
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.1.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 -173
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -352
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -640
- elspais/core/rules.py +0 -514
- elspais/mcp/context.py +0 -171
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -119
- elspais/reformat/hierarchy.py +0 -246
- elspais/reformat/line_breaks.py +0 -220
- elspais/reformat/prompts.py +0 -123
- elspais/reformat/transformer.py +0 -264
- elspais/trace_view/__init__.py +0 -54
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -329
- elspais/trace_view/generators/csv.py +0 -122
- elspais/trace_view/generators/markdown.py +0 -175
- elspais/trace_view/html/__init__.py +0 -31
- elspais/trace_view/html/generator.py +0 -1006
- 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 -353
- elspais/trace_view/review/__init__.py +0 -60
- elspais/trace_view/review/branches.py +0 -1149
- elspais/trace_view/review/models.py +0 -1205
- elspais/trace_view/review/position.py +0 -609
- elspais/trace_view/review/server.py +0 -1056
- elspais/trace_view/review/status.py +0 -470
- elspais/trace_view/review/storage.py +0 -1367
- 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.1.dist-info/RECORD +0 -101
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {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:
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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}"
|