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/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)