elspais 0.9.1__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 +36 -0
- elspais/__main__.py +8 -0
- elspais/cli.py +525 -0
- elspais/commands/__init__.py +12 -0
- elspais/commands/analyze.py +218 -0
- elspais/commands/config_cmd.py +501 -0
- elspais/commands/edit.py +522 -0
- elspais/commands/hash_cmd.py +174 -0
- elspais/commands/index.py +166 -0
- elspais/commands/init.py +177 -0
- elspais/commands/rules_cmd.py +120 -0
- elspais/commands/trace.py +208 -0
- elspais/commands/validate.py +388 -0
- elspais/config/__init__.py +13 -0
- elspais/config/defaults.py +173 -0
- elspais/config/loader.py +494 -0
- elspais/core/__init__.py +21 -0
- elspais/core/content_rules.py +170 -0
- elspais/core/hasher.py +143 -0
- elspais/core/models.py +318 -0
- elspais/core/parser.py +596 -0
- elspais/core/patterns.py +390 -0
- elspais/core/rules.py +514 -0
- elspais/mcp/__init__.py +42 -0
- elspais/mcp/__main__.py +6 -0
- elspais/mcp/context.py +171 -0
- elspais/mcp/serializers.py +112 -0
- elspais/mcp/server.py +339 -0
- elspais/testing/__init__.py +27 -0
- elspais/testing/config.py +48 -0
- elspais/testing/mapper.py +163 -0
- elspais/testing/result_parser.py +289 -0
- elspais/testing/scanner.py +206 -0
- elspais-0.9.1.dist-info/METADATA +393 -0
- elspais-0.9.1.dist-info/RECORD +38 -0
- elspais-0.9.1.dist-info/WHEEL +4 -0
- elspais-0.9.1.dist-info/entry_points.txt +2 -0
- elspais-0.9.1.dist-info/licenses/LICENSE +21 -0
elspais/core/patterns.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""
|
|
2
|
+
elspais.core.patterns - Configurable requirement ID pattern matching.
|
|
3
|
+
|
|
4
|
+
Supports multiple ID formats:
|
|
5
|
+
- HHT style: REQ-p00001, REQ-CAL-d00001
|
|
6
|
+
- Type-prefix style: PRD-00001, OPS-00001, DEV-00001
|
|
7
|
+
- Jira style: PROJ-123
|
|
8
|
+
- Named: REQ-UserAuth
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from elspais.core.models import ParsedRequirement
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class PatternConfig:
|
|
20
|
+
"""
|
|
21
|
+
Configuration for requirement ID patterns.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
id_template: Template string with tokens {prefix}, {associated}, {type}, {id}
|
|
25
|
+
prefix: Base prefix (e.g., "REQ")
|
|
26
|
+
types: Dictionary of type definitions
|
|
27
|
+
id_format: ID format configuration (style, digits, etc.)
|
|
28
|
+
associated: Optional associated repo namespace configuration
|
|
29
|
+
assertions: Optional assertion label format configuration
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
id_template: str
|
|
33
|
+
prefix: str
|
|
34
|
+
types: Dict[str, Dict[str, Any]]
|
|
35
|
+
id_format: Dict[str, Any]
|
|
36
|
+
associated: Optional[Dict[str, Any]] = None
|
|
37
|
+
assertions: Optional[Dict[str, Any]] = None
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, data: Dict[str, Any]) -> "PatternConfig":
|
|
41
|
+
"""Create PatternConfig from configuration dictionary."""
|
|
42
|
+
return cls(
|
|
43
|
+
id_template=data.get("id_template", "{prefix}-{type}{id}"),
|
|
44
|
+
prefix=data.get("prefix", "REQ"),
|
|
45
|
+
types=data.get("types", {}),
|
|
46
|
+
id_format=data.get("id_format", {"style": "numeric", "digits": 5}),
|
|
47
|
+
associated=data.get("associated"),
|
|
48
|
+
assertions=data.get("assertions"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def get_type_by_id(self, type_id: str) -> Optional[Dict[str, Any]]:
|
|
52
|
+
"""Get type configuration by type ID."""
|
|
53
|
+
for config in self.types.values():
|
|
54
|
+
if config.get("id") == type_id:
|
|
55
|
+
return config
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def get_all_type_ids(self) -> List[str]:
|
|
59
|
+
"""Get list of all type IDs."""
|
|
60
|
+
return [config.get("id", "") for config in self.types.values()]
|
|
61
|
+
|
|
62
|
+
def get_assertion_label_pattern(self) -> str:
|
|
63
|
+
"""Get regex pattern for assertion labels based on configuration."""
|
|
64
|
+
assertions = self.assertions or {}
|
|
65
|
+
style = assertions.get("label_style", "uppercase")
|
|
66
|
+
zero_pad = assertions.get("zero_pad", False)
|
|
67
|
+
|
|
68
|
+
if style == "uppercase":
|
|
69
|
+
return r"[A-Z]"
|
|
70
|
+
elif style == "numeric":
|
|
71
|
+
if zero_pad:
|
|
72
|
+
return r"[0-9]{2}"
|
|
73
|
+
return r"[0-9]{1,2}"
|
|
74
|
+
elif style == "alphanumeric":
|
|
75
|
+
return r"[0-9A-Z]"
|
|
76
|
+
elif style == "numeric_1based":
|
|
77
|
+
if zero_pad:
|
|
78
|
+
return r"[0-9]{2}"
|
|
79
|
+
return r"[1-9][0-9]?"
|
|
80
|
+
else:
|
|
81
|
+
return r"[A-Z]"
|
|
82
|
+
|
|
83
|
+
def get_assertion_max_count(self) -> int:
|
|
84
|
+
"""Get maximum number of assertions allowed."""
|
|
85
|
+
assertions = self.assertions or {}
|
|
86
|
+
style = assertions.get("label_style", "uppercase")
|
|
87
|
+
max_count = assertions.get("max_count")
|
|
88
|
+
|
|
89
|
+
if max_count is not None:
|
|
90
|
+
return int(max_count)
|
|
91
|
+
|
|
92
|
+
# Default max based on style
|
|
93
|
+
if style == "uppercase":
|
|
94
|
+
return 26
|
|
95
|
+
elif style == "numeric":
|
|
96
|
+
return 100
|
|
97
|
+
elif style == "alphanumeric":
|
|
98
|
+
return 36
|
|
99
|
+
elif style == "numeric_1based":
|
|
100
|
+
return 99
|
|
101
|
+
return 26
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PatternValidator:
|
|
105
|
+
"""
|
|
106
|
+
Validates and parses requirement IDs against configured patterns.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, config: PatternConfig):
|
|
110
|
+
"""
|
|
111
|
+
Initialize pattern validator.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
config: Pattern configuration
|
|
115
|
+
"""
|
|
116
|
+
self.config = config
|
|
117
|
+
self._regex = self._build_regex()
|
|
118
|
+
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
|
+
)
|
|
122
|
+
|
|
123
|
+
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
|
+
"""
|
|
129
|
+
template = self.config.id_template
|
|
130
|
+
|
|
131
|
+
# Build type alternatives
|
|
132
|
+
type_ids = self.config.get_all_type_ids()
|
|
133
|
+
type_pattern = "|".join(re.escape(t) for t in type_ids if t)
|
|
134
|
+
|
|
135
|
+
# Build ID pattern based on format
|
|
136
|
+
id_format = self.config.id_format
|
|
137
|
+
style = id_format.get("style", "numeric")
|
|
138
|
+
|
|
139
|
+
if style == "numeric":
|
|
140
|
+
digits = int(id_format.get("digits", 5))
|
|
141
|
+
leading_zeros = id_format.get("leading_zeros", True)
|
|
142
|
+
if digits > 0 and leading_zeros:
|
|
143
|
+
id_pattern = f"\\d{{{digits}}}"
|
|
144
|
+
elif digits > 0:
|
|
145
|
+
id_pattern = f"\\d{{1,{digits}}}"
|
|
146
|
+
else:
|
|
147
|
+
id_pattern = "\\d+"
|
|
148
|
+
elif style == "named":
|
|
149
|
+
pattern = id_format.get("pattern", "[A-Za-z][A-Za-z0-9]+")
|
|
150
|
+
id_pattern = pattern
|
|
151
|
+
elif style == "alphanumeric":
|
|
152
|
+
pattern = id_format.get("pattern", "[A-Z0-9]+")
|
|
153
|
+
id_pattern = pattern
|
|
154
|
+
else:
|
|
155
|
+
id_pattern = "[A-Za-z0-9]+"
|
|
156
|
+
|
|
157
|
+
# Build associated pattern if enabled
|
|
158
|
+
associated_config = self.config.associated or {}
|
|
159
|
+
if associated_config.get("enabled"):
|
|
160
|
+
length = associated_config.get("length", 3)
|
|
161
|
+
sep = re.escape(associated_config.get("separator", "-"))
|
|
162
|
+
if length:
|
|
163
|
+
associated_pattern = f"(?P<associated>[A-Z]{{{length}}}){sep}"
|
|
164
|
+
else:
|
|
165
|
+
associated_pattern = f"(?P<associated>[A-Z]+){sep}"
|
|
166
|
+
else:
|
|
167
|
+
associated_pattern = "(?P<associated>)"
|
|
168
|
+
|
|
169
|
+
# Build full regex from template
|
|
170
|
+
# Replace tokens with regex groups
|
|
171
|
+
pattern = template
|
|
172
|
+
pattern = pattern.replace("{prefix}", f"(?P<prefix>{re.escape(self.config.prefix)})")
|
|
173
|
+
|
|
174
|
+
# Handle associated - it's optional
|
|
175
|
+
if "{associated}" in pattern:
|
|
176
|
+
pattern = pattern.replace("{associated}", f"(?:{associated_pattern})?")
|
|
177
|
+
else:
|
|
178
|
+
pattern = pattern.replace("{associated}", "")
|
|
179
|
+
|
|
180
|
+
if type_pattern:
|
|
181
|
+
pattern = pattern.replace("{type}", f"(?P<type>{type_pattern})")
|
|
182
|
+
else:
|
|
183
|
+
pattern = pattern.replace("{type}", "(?P<type>)")
|
|
184
|
+
|
|
185
|
+
pattern = pattern.replace("{id}", f"(?P<id>{id_pattern})")
|
|
186
|
+
|
|
187
|
+
# Optionally add assertion suffix pattern
|
|
188
|
+
if include_assertion:
|
|
189
|
+
assertion_pattern = self.config.get_assertion_label_pattern()
|
|
190
|
+
pattern = f"{pattern}(?:-(?P<assertion>{assertion_pattern}))?"
|
|
191
|
+
|
|
192
|
+
return re.compile(f"^{pattern}$")
|
|
193
|
+
|
|
194
|
+
def parse(self, id_string: str, allow_assertion: bool = False) -> Optional[ParsedRequirement]:
|
|
195
|
+
"""
|
|
196
|
+
Parse a requirement ID string into components.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
id_string: The requirement ID to parse (e.g., "REQ-p00001" or "REQ-p00001-A")
|
|
200
|
+
allow_assertion: If True, allow and parse assertion suffix
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
ParsedRequirement if valid, None if invalid
|
|
204
|
+
"""
|
|
205
|
+
regex = self._regex_with_assertion if allow_assertion else self._regex
|
|
206
|
+
match = regex.match(id_string)
|
|
207
|
+
if not match:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
groups = match.groupdict()
|
|
211
|
+
return ParsedRequirement(
|
|
212
|
+
full_id=id_string,
|
|
213
|
+
prefix=groups.get("prefix", ""),
|
|
214
|
+
associated=groups.get("associated") or None,
|
|
215
|
+
type_code=groups.get("type", ""),
|
|
216
|
+
number=groups.get("id", ""),
|
|
217
|
+
assertion=groups.get("assertion") or None,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def is_valid(self, id_string: str, allow_assertion: bool = False) -> bool:
|
|
221
|
+
"""
|
|
222
|
+
Check if an ID string is valid.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
id_string: The requirement ID to validate
|
|
226
|
+
allow_assertion: If True, allow assertion suffix
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
True if valid, False otherwise
|
|
230
|
+
"""
|
|
231
|
+
return self.parse(id_string, allow_assertion=allow_assertion) is not None
|
|
232
|
+
|
|
233
|
+
def is_valid_assertion_label(self, label: str) -> bool:
|
|
234
|
+
"""
|
|
235
|
+
Check if an assertion label is valid.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
label: The assertion label to validate (e.g., "A", "01")
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
True if valid, False otherwise
|
|
242
|
+
"""
|
|
243
|
+
return self._assertion_label_regex.match(label) is not None
|
|
244
|
+
|
|
245
|
+
def format_assertion_label(self, index: int) -> str:
|
|
246
|
+
"""
|
|
247
|
+
Format an assertion label from a zero-based index.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
index: Zero-based index (0 = A or 00, 1 = B or 01, etc.)
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Formatted assertion label
|
|
254
|
+
"""
|
|
255
|
+
assertions = self.config.assertions or {}
|
|
256
|
+
style = assertions.get("label_style", "uppercase")
|
|
257
|
+
zero_pad = assertions.get("zero_pad", False)
|
|
258
|
+
|
|
259
|
+
if style == "uppercase":
|
|
260
|
+
if index < 0 or index >= 26:
|
|
261
|
+
raise ValueError(f"Index {index} out of range for uppercase labels (0-25)")
|
|
262
|
+
return chr(ord("A") + index)
|
|
263
|
+
elif style == "numeric":
|
|
264
|
+
if zero_pad:
|
|
265
|
+
return f"{index:02d}"
|
|
266
|
+
return str(index)
|
|
267
|
+
elif style == "alphanumeric":
|
|
268
|
+
if index < 10:
|
|
269
|
+
return str(index)
|
|
270
|
+
elif index < 36:
|
|
271
|
+
return chr(ord("A") + index - 10)
|
|
272
|
+
else:
|
|
273
|
+
raise ValueError(f"Index {index} out of range for alphanumeric labels (0-35)")
|
|
274
|
+
elif style == "numeric_1based":
|
|
275
|
+
if zero_pad:
|
|
276
|
+
return f"{index + 1:02d}"
|
|
277
|
+
return str(index + 1)
|
|
278
|
+
else:
|
|
279
|
+
return chr(ord("A") + index)
|
|
280
|
+
|
|
281
|
+
def parse_assertion_label_index(self, label: str) -> int:
|
|
282
|
+
"""
|
|
283
|
+
Parse an assertion label to get its zero-based index.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
label: The assertion label (e.g., "A", "01", "B")
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Zero-based index
|
|
290
|
+
"""
|
|
291
|
+
assertions = self.config.assertions or {}
|
|
292
|
+
style = assertions.get("label_style", "uppercase")
|
|
293
|
+
|
|
294
|
+
if style == "uppercase":
|
|
295
|
+
if len(label) == 1 and label.isupper():
|
|
296
|
+
return ord(label) - ord("A")
|
|
297
|
+
elif style == "numeric":
|
|
298
|
+
return int(label)
|
|
299
|
+
elif style == "alphanumeric":
|
|
300
|
+
if label.isdigit():
|
|
301
|
+
return int(label)
|
|
302
|
+
elif len(label) == 1 and label.isupper():
|
|
303
|
+
return ord(label) - ord("A") + 10
|
|
304
|
+
elif style == "numeric_1based":
|
|
305
|
+
return int(label) - 1
|
|
306
|
+
|
|
307
|
+
raise ValueError(f"Cannot parse assertion label: {label}")
|
|
308
|
+
|
|
309
|
+
def format(
|
|
310
|
+
self, type_code: str, number: int, associated: Optional[str] = None
|
|
311
|
+
) -> str:
|
|
312
|
+
"""
|
|
313
|
+
Format a requirement ID from components.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
type_code: The requirement type code (e.g., "p")
|
|
317
|
+
number: The requirement number
|
|
318
|
+
associated: Optional associated repo code
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Formatted requirement ID string
|
|
322
|
+
"""
|
|
323
|
+
template = self.config.id_template
|
|
324
|
+
id_format = self.config.id_format
|
|
325
|
+
|
|
326
|
+
# Format number
|
|
327
|
+
style = id_format.get("style", "numeric")
|
|
328
|
+
if style == "numeric":
|
|
329
|
+
digits = int(id_format.get("digits", 5))
|
|
330
|
+
leading_zeros = id_format.get("leading_zeros", True)
|
|
331
|
+
if digits > 0 and leading_zeros:
|
|
332
|
+
formatted_number = str(number).zfill(digits)
|
|
333
|
+
else:
|
|
334
|
+
formatted_number = str(number)
|
|
335
|
+
else:
|
|
336
|
+
formatted_number = str(number)
|
|
337
|
+
|
|
338
|
+
# Build result
|
|
339
|
+
result = template
|
|
340
|
+
result = result.replace("{prefix}", self.config.prefix)
|
|
341
|
+
|
|
342
|
+
# Handle associated
|
|
343
|
+
if associated and "{associated}" in result:
|
|
344
|
+
associated_config = self.config.associated or {}
|
|
345
|
+
sep = associated_config.get("separator", "-")
|
|
346
|
+
result = result.replace("{associated}", f"{associated}{sep}")
|
|
347
|
+
else:
|
|
348
|
+
result = result.replace("{associated}", "")
|
|
349
|
+
|
|
350
|
+
result = result.replace("{type}", type_code)
|
|
351
|
+
result = result.replace("{id}", formatted_number)
|
|
352
|
+
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
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"
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
implements_str: The Implements field value
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
List of normalized requirement IDs
|
|
370
|
+
"""
|
|
371
|
+
if not implements_str:
|
|
372
|
+
return []
|
|
373
|
+
|
|
374
|
+
# Split by comma
|
|
375
|
+
parts = [p.strip() for p in implements_str.split(",")]
|
|
376
|
+
result = []
|
|
377
|
+
|
|
378
|
+
for part in parts:
|
|
379
|
+
if not part:
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
# Check if it's a full ID
|
|
383
|
+
if self.is_valid(part):
|
|
384
|
+
result.append(part)
|
|
385
|
+
else:
|
|
386
|
+
# It might be a shortened ID like "p00001" or "CAL-p00001"
|
|
387
|
+
# Just keep the raw value for later resolution
|
|
388
|
+
result.append(part)
|
|
389
|
+
|
|
390
|
+
return result
|