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/hasher.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
elspais.core.hasher - Hash calculation for requirement change detection.
|
|
3
|
+
|
|
4
|
+
Provides functions for calculating and verifying SHA-256 based content hashes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def clean_requirement_body(content: str, normalize_whitespace: bool = False) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Clean requirement body text for consistent hashing.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
content: Raw requirement body text
|
|
18
|
+
normalize_whitespace: If True, aggressively normalize whitespace.
|
|
19
|
+
If False (default), only remove trailing blank lines
|
|
20
|
+
(matches hht-diary tools behavior).
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Cleaned text suitable for hashing
|
|
24
|
+
"""
|
|
25
|
+
if normalize_whitespace:
|
|
26
|
+
# Aggressive normalization mode
|
|
27
|
+
lines = content.split("\n")
|
|
28
|
+
|
|
29
|
+
# Remove leading blank lines
|
|
30
|
+
while lines and not lines[0].strip():
|
|
31
|
+
lines.pop(0)
|
|
32
|
+
|
|
33
|
+
# Remove trailing blank lines
|
|
34
|
+
while lines and not lines[-1].strip():
|
|
35
|
+
lines.pop()
|
|
36
|
+
|
|
37
|
+
# Strip trailing whitespace from each line
|
|
38
|
+
lines = [line.rstrip() for line in lines]
|
|
39
|
+
|
|
40
|
+
# Collapse multiple consecutive blank lines to single blank line
|
|
41
|
+
result_lines = []
|
|
42
|
+
prev_blank = False
|
|
43
|
+
for line in lines:
|
|
44
|
+
is_blank = not line.strip()
|
|
45
|
+
if is_blank and prev_blank:
|
|
46
|
+
continue
|
|
47
|
+
result_lines.append(line)
|
|
48
|
+
prev_blank = is_blank
|
|
49
|
+
|
|
50
|
+
return "\n".join(result_lines)
|
|
51
|
+
else:
|
|
52
|
+
# Default: hht-diary compatible mode (only remove trailing blank lines)
|
|
53
|
+
lines = content.split("\n")
|
|
54
|
+
|
|
55
|
+
# Remove trailing blank lines (matches hht-diary behavior)
|
|
56
|
+
while lines and not lines[-1].strip():
|
|
57
|
+
lines.pop()
|
|
58
|
+
|
|
59
|
+
return "\n".join(lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def calculate_hash(
|
|
63
|
+
content: str,
|
|
64
|
+
length: int = 8,
|
|
65
|
+
algorithm: str = "sha256",
|
|
66
|
+
normalize_whitespace: bool = False,
|
|
67
|
+
) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Calculate a content hash for change detection.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
content: Text content to hash
|
|
73
|
+
length: Number of characters in the hash (default 8)
|
|
74
|
+
algorithm: Hash algorithm to use (default "sha256")
|
|
75
|
+
normalize_whitespace: If True, aggressively normalize whitespace.
|
|
76
|
+
If False (default), only remove trailing blank lines.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Hexadecimal hash string of specified length
|
|
80
|
+
"""
|
|
81
|
+
# Clean the content first
|
|
82
|
+
cleaned = clean_requirement_body(content, normalize_whitespace=normalize_whitespace)
|
|
83
|
+
|
|
84
|
+
# Calculate hash
|
|
85
|
+
if algorithm == "sha256":
|
|
86
|
+
hash_obj = hashlib.sha256(cleaned.encode("utf-8"))
|
|
87
|
+
elif algorithm == "sha1":
|
|
88
|
+
hash_obj = hashlib.sha1(cleaned.encode("utf-8"))
|
|
89
|
+
elif algorithm == "md5":
|
|
90
|
+
hash_obj = hashlib.md5(cleaned.encode("utf-8"))
|
|
91
|
+
else:
|
|
92
|
+
raise ValueError(f"Unsupported hash algorithm: {algorithm}")
|
|
93
|
+
|
|
94
|
+
# Return first `length` characters of hex digest
|
|
95
|
+
return hash_obj.hexdigest()[:length]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def verify_hash(
|
|
99
|
+
content: str,
|
|
100
|
+
expected_hash: str,
|
|
101
|
+
length: int = 8,
|
|
102
|
+
algorithm: str = "sha256",
|
|
103
|
+
normalize_whitespace: bool = False,
|
|
104
|
+
) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Verify that content matches an expected hash.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
content: Text content to verify
|
|
110
|
+
expected_hash: Expected hash value
|
|
111
|
+
length: Hash length used (default 8)
|
|
112
|
+
algorithm: Hash algorithm used (default "sha256")
|
|
113
|
+
normalize_whitespace: If True, aggressively normalize whitespace.
|
|
114
|
+
If False (default), only remove trailing blank lines.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True if hash matches, False otherwise
|
|
118
|
+
"""
|
|
119
|
+
actual_hash = calculate_hash(
|
|
120
|
+
content,
|
|
121
|
+
length=length,
|
|
122
|
+
algorithm=algorithm,
|
|
123
|
+
normalize_whitespace=normalize_whitespace,
|
|
124
|
+
)
|
|
125
|
+
return actual_hash.lower() == expected_hash.lower()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def extract_hash_from_footer(footer_text: str) -> Optional[str]:
|
|
129
|
+
"""
|
|
130
|
+
Extract hash value from requirement footer line.
|
|
131
|
+
|
|
132
|
+
Looks for pattern: **Hash**: XXXXXXXX
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
footer_text: The footer line text
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Hash string if found, None otherwise
|
|
139
|
+
"""
|
|
140
|
+
match = re.search(r"\*\*Hash\*\*:\s*([a-fA-F0-9]+)", footer_text)
|
|
141
|
+
if match:
|
|
142
|
+
return match.group(1)
|
|
143
|
+
return None
|
elspais/core/models.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
elspais.core.models - Core data models for requirements.
|
|
3
|
+
|
|
4
|
+
Provides dataclasses for representing requirements, parsed IDs,
|
|
5
|
+
and requirement types.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class RequirementType:
|
|
16
|
+
"""
|
|
17
|
+
Represents a requirement type (PRD, OPS, DEV, etc.).
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
id: The type identifier used in requirement IDs (e.g., "p", "PRD")
|
|
21
|
+
name: Human-readable name (e.g., "Product Requirement")
|
|
22
|
+
level: Hierarchy level (1=highest/parent, higher numbers=children)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
name: str = ""
|
|
27
|
+
level: int = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Assertion:
|
|
32
|
+
"""
|
|
33
|
+
Represents a single assertion within a requirement.
|
|
34
|
+
|
|
35
|
+
Assertions are the unit of verification - each defines one testable
|
|
36
|
+
obligation using SHALL/SHALL NOT language.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
label: The assertion label (e.g., "A", "B", "01", "0A")
|
|
40
|
+
text: The assertion text (e.g., "The system SHALL...")
|
|
41
|
+
is_placeholder: True if text indicates removed/deprecated assertion
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
label: str
|
|
45
|
+
text: str
|
|
46
|
+
is_placeholder: bool = False
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def full_id(self) -> str:
|
|
50
|
+
"""Return the assertion ID suffix (e.g., "-A")."""
|
|
51
|
+
return f"-{self.label}"
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
return f"{self.label}. {self.text}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ParsedRequirement:
|
|
59
|
+
"""
|
|
60
|
+
Represents a parsed requirement ID broken into components.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
full_id: The complete requirement ID (e.g., "REQ-CAL-p00001" or "REQ-p00001-A")
|
|
64
|
+
prefix: The ID prefix (e.g., "REQ")
|
|
65
|
+
associated: Optional associated repo namespace (e.g., "CAL")
|
|
66
|
+
type_code: The requirement type code (e.g., "p")
|
|
67
|
+
number: The ID number or name (e.g., "00001")
|
|
68
|
+
assertion: Optional assertion label (e.g., "A", "01")
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
full_id: str
|
|
72
|
+
prefix: str
|
|
73
|
+
associated: Optional[str]
|
|
74
|
+
type_code: str
|
|
75
|
+
number: str
|
|
76
|
+
assertion: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def base_id(self) -> str:
|
|
80
|
+
"""Return the requirement ID without assertion suffix."""
|
|
81
|
+
if self.assertion:
|
|
82
|
+
return self.full_id.rsplit("-", 1)[0]
|
|
83
|
+
return self.full_id
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Requirement:
|
|
88
|
+
"""
|
|
89
|
+
Represents a complete requirement specification.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
id: Unique requirement identifier (e.g., "REQ-p00001")
|
|
93
|
+
title: Requirement title
|
|
94
|
+
level: Requirement level/type name (e.g., "PRD", "DEV")
|
|
95
|
+
status: Current status (e.g., "Active", "Draft")
|
|
96
|
+
body: Main requirement text
|
|
97
|
+
implements: List of requirement IDs this requirement implements
|
|
98
|
+
acceptance_criteria: List of acceptance criteria (legacy format)
|
|
99
|
+
assertions: List of Assertion objects (new format)
|
|
100
|
+
rationale: Optional rationale text
|
|
101
|
+
hash: Content hash for change detection
|
|
102
|
+
file_path: Source file path
|
|
103
|
+
line_number: Line number in source file
|
|
104
|
+
tags: Optional list of tags
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
id: str
|
|
108
|
+
title: str
|
|
109
|
+
level: str
|
|
110
|
+
status: str
|
|
111
|
+
body: str
|
|
112
|
+
implements: List[str] = field(default_factory=list)
|
|
113
|
+
acceptance_criteria: List[str] = field(default_factory=list)
|
|
114
|
+
assertions: List["Assertion"] = field(default_factory=list)
|
|
115
|
+
rationale: Optional[str] = None
|
|
116
|
+
hash: Optional[str] = None
|
|
117
|
+
file_path: Optional[Path] = None
|
|
118
|
+
line_number: Optional[int] = None
|
|
119
|
+
tags: List[str] = field(default_factory=list)
|
|
120
|
+
subdir: str = "" # Subdirectory within spec/, e.g., "roadmap", "archive", ""
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def type_code(self) -> str:
|
|
124
|
+
"""
|
|
125
|
+
Extract the type code from the requirement ID.
|
|
126
|
+
|
|
127
|
+
For REQ-p00001, returns "p".
|
|
128
|
+
For REQ-CAL-d00001, returns "d".
|
|
129
|
+
For PRD-00001, returns "PRD".
|
|
130
|
+
"""
|
|
131
|
+
# Try to extract type code from ID
|
|
132
|
+
# Pattern: after last separator, before numbers
|
|
133
|
+
match = re.search(r"-([a-zA-Z]+)\d", self.id)
|
|
134
|
+
if match:
|
|
135
|
+
return match.group(1)
|
|
136
|
+
|
|
137
|
+
# Pattern: type at start (e.g., PRD-00001)
|
|
138
|
+
match = re.match(r"([A-Z]+)-\d", self.id)
|
|
139
|
+
if match:
|
|
140
|
+
return match.group(1)
|
|
141
|
+
|
|
142
|
+
return ""
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def number(self) -> int:
|
|
146
|
+
"""
|
|
147
|
+
Extract the numeric ID from the requirement ID.
|
|
148
|
+
|
|
149
|
+
For REQ-p00001, returns 1.
|
|
150
|
+
For REQ-d00042, returns 42.
|
|
151
|
+
"""
|
|
152
|
+
match = re.search(r"(\d+)$", self.id)
|
|
153
|
+
if match:
|
|
154
|
+
return int(match.group(1))
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def associated(self) -> Optional[str]:
|
|
159
|
+
"""
|
|
160
|
+
Extract the associated repo code from the requirement ID.
|
|
161
|
+
|
|
162
|
+
For REQ-CAL-d00001, returns "CAL".
|
|
163
|
+
For REQ-p00001, returns None.
|
|
164
|
+
"""
|
|
165
|
+
# Pattern: REQ-XXX- where XXX is 2-4 uppercase letters
|
|
166
|
+
match = re.search(r"^[A-Z]+-([A-Z]{2,4})-", self.id)
|
|
167
|
+
if match:
|
|
168
|
+
return match.group(1)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def is_roadmap(self) -> bool:
|
|
173
|
+
"""
|
|
174
|
+
Check if this requirement is from the roadmap subdirectory.
|
|
175
|
+
|
|
176
|
+
Returns True if subdir is "roadmap", False otherwise.
|
|
177
|
+
This is a convenience property for backward compatibility.
|
|
178
|
+
"""
|
|
179
|
+
return self.subdir == "roadmap"
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def spec_path(self) -> str:
|
|
183
|
+
"""
|
|
184
|
+
Return the spec-relative file path as a string.
|
|
185
|
+
|
|
186
|
+
For requirements in spec/prd-core.md, returns "spec/prd-core.md".
|
|
187
|
+
For requirements in spec/roadmap/prd-future.md, returns "spec/roadmap/prd-future.md".
|
|
188
|
+
"""
|
|
189
|
+
if self.file_path:
|
|
190
|
+
return str(self.file_path)
|
|
191
|
+
return ""
|
|
192
|
+
|
|
193
|
+
def location(self) -> str:
|
|
194
|
+
"""Return file:line location string."""
|
|
195
|
+
if self.file_path and self.line_number:
|
|
196
|
+
return f"{self.file_path}:{self.line_number}"
|
|
197
|
+
elif self.file_path:
|
|
198
|
+
return str(self.file_path)
|
|
199
|
+
return "unknown"
|
|
200
|
+
|
|
201
|
+
def get_assertion(self, label: str) -> Optional["Assertion"]:
|
|
202
|
+
"""Get an assertion by its label."""
|
|
203
|
+
for assertion in self.assertions:
|
|
204
|
+
if assertion.label == label:
|
|
205
|
+
return assertion
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
def assertion_id(self, label: str) -> str:
|
|
209
|
+
"""Return the full assertion ID (e.g., 'REQ-p00001-A')."""
|
|
210
|
+
return f"{self.id}-{label}"
|
|
211
|
+
|
|
212
|
+
def __str__(self) -> str:
|
|
213
|
+
return f"{self.id}: {self.title}"
|
|
214
|
+
|
|
215
|
+
def __repr__(self) -> str:
|
|
216
|
+
return f"Requirement(id={self.id!r}, title={self.title!r}, level={self.level!r})"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class ContentRule:
|
|
221
|
+
"""
|
|
222
|
+
Represents a content rule file for semantic validation guidance.
|
|
223
|
+
|
|
224
|
+
Content rules are markdown files that provide guidance to AI agents
|
|
225
|
+
and humans when authoring requirements. They can include YAML frontmatter
|
|
226
|
+
for metadata.
|
|
227
|
+
|
|
228
|
+
Attributes:
|
|
229
|
+
file_path: Path to the content rule file
|
|
230
|
+
title: Human-readable title (from frontmatter or filename)
|
|
231
|
+
content: Full markdown content (excluding frontmatter)
|
|
232
|
+
type: Rule type - "guidance", "specification", or "template"
|
|
233
|
+
applies_to: List of what this rule applies to (e.g., ["requirements", "assertions"])
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
file_path: Path
|
|
237
|
+
title: str
|
|
238
|
+
content: str
|
|
239
|
+
type: str = "guidance"
|
|
240
|
+
applies_to: List[str] = field(default_factory=list)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass
|
|
244
|
+
class ParseWarning:
|
|
245
|
+
"""
|
|
246
|
+
Parser-level warning about a requirement.
|
|
247
|
+
|
|
248
|
+
Warnings indicate issues found during parsing that don't prevent
|
|
249
|
+
the requirement from being parsed, but may indicate problems.
|
|
250
|
+
|
|
251
|
+
Attributes:
|
|
252
|
+
requirement_id: The requirement ID this warning relates to
|
|
253
|
+
message: Human-readable warning message
|
|
254
|
+
file_path: Source file path (optional)
|
|
255
|
+
line_number: Line number in source file (optional)
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
requirement_id: str
|
|
259
|
+
message: str
|
|
260
|
+
file_path: Optional[Path] = None
|
|
261
|
+
line_number: Optional[int] = None
|
|
262
|
+
|
|
263
|
+
def __str__(self) -> str:
|
|
264
|
+
location = ""
|
|
265
|
+
if self.file_path:
|
|
266
|
+
location = f" at {self.file_path}"
|
|
267
|
+
if self.line_number:
|
|
268
|
+
location = f" at {self.file_path}:{self.line_number}"
|
|
269
|
+
return f"[{self.requirement_id}] {self.message}{location}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class ParseResult:
|
|
274
|
+
"""
|
|
275
|
+
Result of parsing requirements from text or files.
|
|
276
|
+
|
|
277
|
+
Contains both the successfully parsed requirements and any
|
|
278
|
+
warnings generated during parsing.
|
|
279
|
+
|
|
280
|
+
Attributes:
|
|
281
|
+
requirements: Dictionary of requirement ID to Requirement
|
|
282
|
+
warnings: List of parser warnings
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
requirements: Dict[str, "Requirement"]
|
|
286
|
+
warnings: List[ParseWarning] = field(default_factory=list)
|
|
287
|
+
|
|
288
|
+
def __getitem__(self, key: str) -> "Requirement":
|
|
289
|
+
"""Get a requirement by ID."""
|
|
290
|
+
return self.requirements[key]
|
|
291
|
+
|
|
292
|
+
def __contains__(self, key: str) -> bool:
|
|
293
|
+
"""Check if a requirement ID exists."""
|
|
294
|
+
return key in self.requirements
|
|
295
|
+
|
|
296
|
+
def __len__(self) -> int:
|
|
297
|
+
"""Return the number of requirements."""
|
|
298
|
+
return len(self.requirements)
|
|
299
|
+
|
|
300
|
+
def __iter__(self):
|
|
301
|
+
"""Iterate over requirement IDs."""
|
|
302
|
+
return iter(self.requirements)
|
|
303
|
+
|
|
304
|
+
def items(self):
|
|
305
|
+
"""Return items like a dict."""
|
|
306
|
+
return self.requirements.items()
|
|
307
|
+
|
|
308
|
+
def keys(self):
|
|
309
|
+
"""Return keys like a dict."""
|
|
310
|
+
return self.requirements.keys()
|
|
311
|
+
|
|
312
|
+
def values(self):
|
|
313
|
+
"""Return values like a dict."""
|
|
314
|
+
return self.requirements.values()
|
|
315
|
+
|
|
316
|
+
def get(self, key: str, default=None) -> Optional["Requirement"]:
|
|
317
|
+
"""Get a requirement by ID with default."""
|
|
318
|
+
return self.requirements.get(key, default)
|