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/rules.py ADDED
@@ -0,0 +1,514 @@
1
+ """
2
+ elspais.core.rules - Validation rule engine.
3
+
4
+ Provides configurable validation rules for requirement hierarchies,
5
+ format compliance, and traceability.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from typing import Any, Dict, List, Optional, Set
11
+
12
+ from elspais.core.models import Requirement
13
+ from elspais.core.patterns import PatternConfig, PatternValidator
14
+
15
+
16
+ class Severity(Enum):
17
+ """Severity level for rule violations."""
18
+
19
+ ERROR = "error"
20
+ WARNING = "warning"
21
+ INFO = "info"
22
+
23
+
24
+ @dataclass
25
+ class RuleViolation:
26
+ """
27
+ Represents a rule violation found during validation.
28
+
29
+ Attributes:
30
+ rule_name: Name of the violated rule (e.g., "hierarchy.circular")
31
+ requirement_id: ID of the requirement with the violation
32
+ message: Human-readable description of the violation
33
+ severity: Severity level
34
+ location: File:line location string
35
+ """
36
+
37
+ rule_name: str
38
+ requirement_id: str
39
+ message: str
40
+ severity: Severity
41
+ location: str = ""
42
+
43
+ def __str__(self) -> str:
44
+ prefix = {
45
+ Severity.ERROR: "❌ ERROR",
46
+ Severity.WARNING: "⚠️ WARNING",
47
+ Severity.INFO: "ℹ️ INFO",
48
+ }.get(self.severity, "?")
49
+ return (
50
+ f"{prefix} [{self.rule_name}] {self.requirement_id}\n"
51
+ f" {self.message}\n {self.location}"
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class HierarchyConfig:
57
+ """Configuration for hierarchy validation rules."""
58
+
59
+ allowed_implements: List[str] = field(default_factory=list)
60
+ allow_circular: bool = False
61
+ allow_orphans: bool = False
62
+ max_depth: int = 5
63
+ cross_repo_implements: bool = True
64
+
65
+ # Parsed allowed relationships: source_type -> set of allowed target types
66
+ _allowed_map: Dict[str, Set[str]] = field(default_factory=dict, repr=False)
67
+
68
+ def __post_init__(self) -> None:
69
+ """Parse allowed_implements into a lookup map."""
70
+ self._allowed_map = {}
71
+ for rule in self.allowed_implements:
72
+ # Parse "dev -> ops, prd"
73
+ parts = rule.split("->")
74
+ if len(parts) == 2:
75
+ source = parts[0].strip().lower()
76
+ targets = [t.strip().lower() for t in parts[1].split(",")]
77
+ self._allowed_map[source] = set(targets)
78
+
79
+ def can_implement(self, source_type: str, target_type: str) -> bool:
80
+ """Check if source type can implement target type."""
81
+ source = source_type.lower()
82
+ target = target_type.lower()
83
+ allowed = self._allowed_map.get(source, set())
84
+ return target in allowed
85
+
86
+
87
+ @dataclass
88
+ class FormatConfig:
89
+ """Configuration for format validation rules."""
90
+
91
+ require_hash: bool = True
92
+ require_rationale: bool = False
93
+ require_status: bool = True
94
+ allowed_statuses: List[str] = field(
95
+ default_factory=lambda: ["Active", "Draft", "Deprecated", "Superseded"]
96
+ )
97
+
98
+ # Assertion format rules
99
+ require_assertions: bool = True
100
+ acceptance_criteria: str = "warn" # "allow" | "warn" | "error"
101
+ require_shall: bool = True
102
+ labels_sequential: bool = True
103
+ labels_unique: bool = True
104
+ placeholder_values: List[str] = field(default_factory=lambda: [
105
+ "obsolete", "removed", "deprecated", "N/A", "n/a", "-", "reserved"
106
+ ])
107
+
108
+
109
+ @dataclass
110
+ class RulesConfig:
111
+ """Complete configuration for all validation rules."""
112
+
113
+ hierarchy: HierarchyConfig = field(default_factory=HierarchyConfig)
114
+ format: FormatConfig = field(default_factory=FormatConfig)
115
+
116
+ @classmethod
117
+ def from_dict(cls, data: Dict[str, Any]) -> "RulesConfig":
118
+ """Create RulesConfig from configuration dictionary."""
119
+ hierarchy_data = data.get("hierarchy", {})
120
+ format_data = data.get("format", {})
121
+
122
+ hierarchy = HierarchyConfig(
123
+ allowed_implements=hierarchy_data.get(
124
+ "allowed_implements", ["dev -> ops, prd", "ops -> prd", "prd -> prd"]
125
+ ),
126
+ allow_circular=hierarchy_data.get("allow_circular", False),
127
+ allow_orphans=hierarchy_data.get("allow_orphans", False),
128
+ max_depth=hierarchy_data.get("max_depth", 5),
129
+ cross_repo_implements=hierarchy_data.get("cross_repo_implements", True),
130
+ )
131
+
132
+ format_config = FormatConfig(
133
+ require_hash=format_data.get("require_hash", True),
134
+ require_rationale=format_data.get("require_rationale", False),
135
+ require_status=format_data.get("require_status", True),
136
+ allowed_statuses=format_data.get(
137
+ "allowed_statuses", ["Active", "Draft", "Deprecated", "Superseded"]
138
+ ),
139
+ # Assertion rules
140
+ require_assertions=format_data.get("require_assertions", True),
141
+ acceptance_criteria=format_data.get("acceptance_criteria", "warn"),
142
+ require_shall=format_data.get("require_shall", True),
143
+ labels_sequential=format_data.get("labels_sequential", True),
144
+ labels_unique=format_data.get("labels_unique", True),
145
+ placeholder_values=format_data.get("placeholder_values", [
146
+ "obsolete", "removed", "deprecated", "N/A", "n/a", "-", "reserved"
147
+ ]),
148
+ )
149
+
150
+ return cls(hierarchy=hierarchy, format=format_config)
151
+
152
+
153
+ class RuleEngine:
154
+ """
155
+ Validates requirements against configured rules.
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ config: RulesConfig,
161
+ pattern_config: Optional[PatternConfig] = None,
162
+ ):
163
+ """
164
+ Initialize rule engine.
165
+
166
+ Args:
167
+ config: Rules configuration
168
+ pattern_config: Optional pattern configuration for assertion label validation
169
+ """
170
+ self.config = config
171
+ self.pattern_config = pattern_config
172
+ self.pattern_validator = (
173
+ PatternValidator(pattern_config) if pattern_config else None
174
+ )
175
+
176
+ def validate(self, requirements: Dict[str, Requirement]) -> List[RuleViolation]:
177
+ """
178
+ Validate all requirements against configured rules.
179
+
180
+ Args:
181
+ requirements: Dictionary of requirement ID -> Requirement
182
+
183
+ Returns:
184
+ List of RuleViolation objects
185
+ """
186
+ violations = []
187
+
188
+ # Run all validation rules
189
+ violations.extend(self._check_hierarchy(requirements))
190
+ violations.extend(self._check_format(requirements))
191
+ violations.extend(self._check_circular(requirements))
192
+ violations.extend(self._check_orphans(requirements))
193
+
194
+ return violations
195
+
196
+ def _check_hierarchy(self, requirements: Dict[str, Requirement]) -> List[RuleViolation]:
197
+ """Check hierarchy rules (allowed implements)."""
198
+ violations = []
199
+
200
+ for req_id, req in requirements.items():
201
+ source_type = self._get_type_from_level(req.level)
202
+
203
+ for impl_id in req.implements:
204
+ # Find the target requirement
205
+ target_req = self._find_requirement(impl_id, requirements)
206
+ if target_req is None:
207
+ # Target not found - this is a broken link, not hierarchy violation
208
+ continue
209
+
210
+ target_type = self._get_type_from_level(target_req.level)
211
+
212
+ # Check if this relationship is allowed
213
+ if not self.config.hierarchy.can_implement(source_type, target_type):
214
+ msg = (
215
+ f"{source_type.upper()} cannot implement "
216
+ f"{target_type.upper()} ({impl_id})"
217
+ )
218
+ violations.append(
219
+ RuleViolation(
220
+ rule_name="hierarchy.implements",
221
+ requirement_id=req_id,
222
+ message=msg,
223
+ severity=Severity.ERROR,
224
+ location=req.location(),
225
+ )
226
+ )
227
+
228
+ return violations
229
+
230
+ def _check_circular(self, requirements: Dict[str, Requirement]) -> List[RuleViolation]:
231
+ """Check for circular dependencies."""
232
+ if self.config.hierarchy.allow_circular:
233
+ return []
234
+
235
+ violations: List[RuleViolation] = []
236
+ visited: Set[str] = set()
237
+ path: List[str] = []
238
+
239
+ def dfs(req_id: str) -> Optional[List[str]]:
240
+ """Depth-first search for cycles."""
241
+ if req_id in path:
242
+ # Found a cycle
243
+ cycle_start = path.index(req_id)
244
+ return path[cycle_start:] + [req_id]
245
+
246
+ if req_id in visited:
247
+ return None
248
+
249
+ visited.add(req_id)
250
+ path.append(req_id)
251
+
252
+ req = requirements.get(req_id)
253
+ if req:
254
+ for impl_id in req.implements:
255
+ # Resolve to full ID if needed
256
+ full_id = self._resolve_id(impl_id, requirements)
257
+ if full_id and full_id in requirements:
258
+ cycle = dfs(full_id)
259
+ if cycle:
260
+ return cycle
261
+
262
+ path.pop()
263
+ return None
264
+
265
+ # Check each requirement for cycles
266
+ for req_id in requirements:
267
+ visited.clear()
268
+ path.clear()
269
+ cycle = dfs(req_id)
270
+ if cycle:
271
+ cycle_str = " -> ".join(cycle)
272
+ violations.append(
273
+ RuleViolation(
274
+ rule_name="hierarchy.circular",
275
+ requirement_id=req_id,
276
+ message=f"Circular dependency detected: {cycle_str}",
277
+ severity=Severity.ERROR,
278
+ location=requirements[req_id].location(),
279
+ )
280
+ )
281
+ break # Report only first cycle found
282
+
283
+ return violations
284
+
285
+ def _check_orphans(self, requirements: Dict[str, Requirement]) -> List[RuleViolation]:
286
+ """Check for orphaned requirements (DEV/OPS without implements)."""
287
+ if self.config.hierarchy.allow_orphans:
288
+ return []
289
+
290
+ violations = []
291
+
292
+ for req_id, req in requirements.items():
293
+ # Skip root level (PRD)
294
+ if req.level.upper() in ["PRD", "PRODUCT"]:
295
+ continue
296
+
297
+ # DEV/OPS should implement something
298
+ if not req.implements:
299
+ violations.append(
300
+ RuleViolation(
301
+ rule_name="hierarchy.orphan",
302
+ requirement_id=req_id,
303
+ message=f"{req.level} requirement has no Implements reference",
304
+ severity=Severity.WARNING,
305
+ location=req.location(),
306
+ )
307
+ )
308
+
309
+ return violations
310
+
311
+ def _check_format(self, requirements: Dict[str, Requirement]) -> List[RuleViolation]:
312
+ """Check format rules (hash, rationale, assertions, acceptance criteria)."""
313
+ violations = []
314
+
315
+ for req_id, req in requirements.items():
316
+ # Check hash
317
+ if self.config.format.require_hash and not req.hash:
318
+ violations.append(
319
+ RuleViolation(
320
+ rule_name="format.require_hash",
321
+ requirement_id=req_id,
322
+ message="Missing hash footer",
323
+ severity=Severity.ERROR,
324
+ location=req.location(),
325
+ )
326
+ )
327
+
328
+ # Check rationale
329
+ if self.config.format.require_rationale and not req.rationale:
330
+ violations.append(
331
+ RuleViolation(
332
+ rule_name="format.require_rationale",
333
+ requirement_id=req_id,
334
+ message="Missing Rationale section",
335
+ severity=Severity.WARNING,
336
+ location=req.location(),
337
+ )
338
+ )
339
+
340
+ # Check assertions (new format)
341
+ violations.extend(self._check_assertions(req_id, req))
342
+
343
+ # Check acceptance criteria (legacy format)
344
+ acceptance_mode = self.config.format.acceptance_criteria
345
+ if req.acceptance_criteria:
346
+ if acceptance_mode == "error":
347
+ violations.append(
348
+ RuleViolation(
349
+ rule_name="format.acceptance_criteria",
350
+ requirement_id=req_id,
351
+ message="Acceptance Criteria not allowed; use Assertions",
352
+ severity=Severity.ERROR,
353
+ location=req.location(),
354
+ )
355
+ )
356
+ elif acceptance_mode == "warn":
357
+ violations.append(
358
+ RuleViolation(
359
+ rule_name="format.acceptance_criteria",
360
+ requirement_id=req_id,
361
+ message="Acceptance Criteria deprecated; use Assertions",
362
+ severity=Severity.WARNING,
363
+ location=req.location(),
364
+ )
365
+ )
366
+ # "allow" mode: no violation
367
+
368
+ # Check status
369
+ if self.config.format.require_status:
370
+ if req.status not in self.config.format.allowed_statuses:
371
+ allowed = self.config.format.allowed_statuses
372
+ violations.append(
373
+ RuleViolation(
374
+ rule_name="format.status_valid",
375
+ requirement_id=req_id,
376
+ message=f"Invalid status '{req.status}'. Allowed: {allowed}",
377
+ severity=Severity.ERROR,
378
+ location=req.location(),
379
+ )
380
+ )
381
+
382
+ return violations
383
+
384
+ def _check_assertions(
385
+ self, req_id: str, req: Requirement
386
+ ) -> List[RuleViolation]:
387
+ """Check assertion-specific validation rules."""
388
+ violations = []
389
+
390
+ # Check if assertions are required
391
+ if self.config.format.require_assertions and not req.assertions:
392
+ violations.append(
393
+ RuleViolation(
394
+ rule_name="format.require_assertions",
395
+ requirement_id=req_id,
396
+ message="Missing Assertions section",
397
+ severity=Severity.ERROR,
398
+ location=req.location(),
399
+ )
400
+ )
401
+ return violations # No point checking other assertion rules
402
+
403
+ if not req.assertions:
404
+ return violations
405
+
406
+ # Extract labels and check for duplicates
407
+ labels = [a.label for a in req.assertions]
408
+
409
+ # Check labels are unique
410
+ if self.config.format.labels_unique:
411
+ seen = set()
412
+ for label in labels:
413
+ if label in seen:
414
+ violations.append(
415
+ RuleViolation(
416
+ rule_name="format.labels_unique",
417
+ requirement_id=req_id,
418
+ message=f"Duplicate assertion label: {label}",
419
+ severity=Severity.ERROR,
420
+ location=req.location(),
421
+ )
422
+ )
423
+ seen.add(label)
424
+
425
+ # Check labels are sequential
426
+ if self.config.format.labels_sequential and self.pattern_validator:
427
+ expected_labels = []
428
+ for i in range(len(labels)):
429
+ expected_labels.append(
430
+ self.pattern_validator.format_assertion_label(i)
431
+ )
432
+ if labels != expected_labels:
433
+ msg = f"Labels not sequential: {labels} (expected {expected_labels})"
434
+ violations.append(
435
+ RuleViolation(
436
+ rule_name="format.labels_sequential",
437
+ requirement_id=req_id,
438
+ message=msg,
439
+ severity=Severity.ERROR,
440
+ location=req.location(),
441
+ )
442
+ )
443
+
444
+ # Check SHALL/SHALL NOT language (skip placeholders)
445
+ if self.config.format.require_shall:
446
+ for assertion in req.assertions:
447
+ if assertion.is_placeholder:
448
+ continue
449
+ if "SHALL" not in assertion.text.upper():
450
+ text_preview = assertion.text[:40]
451
+ msg = f"Assertion {assertion.label} missing SHALL: {text_preview}..."
452
+ violations.append(
453
+ RuleViolation(
454
+ rule_name="format.require_shall",
455
+ requirement_id=req_id,
456
+ message=msg,
457
+ severity=Severity.WARNING,
458
+ location=req.location(),
459
+ )
460
+ )
461
+
462
+ # Validate assertion labels against configured pattern
463
+ if self.pattern_validator:
464
+ for assertion in req.assertions:
465
+ if not self.pattern_validator.is_valid_assertion_label(assertion.label):
466
+ violations.append(
467
+ RuleViolation(
468
+ rule_name="format.assertion_label",
469
+ requirement_id=req_id,
470
+ message=f"Invalid assertion label format: {assertion.label}",
471
+ severity=Severity.ERROR,
472
+ location=req.location(),
473
+ )
474
+ )
475
+
476
+ return violations
477
+
478
+ def _get_type_from_level(self, level: str) -> str:
479
+ """Map level name to type code."""
480
+ level_map = {
481
+ "PRD": "prd",
482
+ "PRODUCT": "prd",
483
+ "OPS": "ops",
484
+ "OPERATIONS": "ops",
485
+ "DEV": "dev",
486
+ "DEVELOPMENT": "dev",
487
+ }
488
+ return level_map.get(level.upper(), level.lower())
489
+
490
+ def _find_requirement(
491
+ self, impl_id: str, requirements: Dict[str, Requirement]
492
+ ) -> Optional[Requirement]:
493
+ """Find a requirement by ID (handles partial IDs)."""
494
+ # Try exact match first
495
+ if impl_id in requirements:
496
+ return requirements[impl_id]
497
+
498
+ # Try to find by suffix (e.g., "p00001" matches "REQ-p00001")
499
+ for req_id, req in requirements.items():
500
+ if req_id.endswith(impl_id) or req_id.endswith(f"-{impl_id}"):
501
+ return req
502
+
503
+ return None
504
+
505
+ def _resolve_id(self, impl_id: str, requirements: Dict[str, Requirement]) -> Optional[str]:
506
+ """Resolve a partial ID to a full ID."""
507
+ if impl_id in requirements:
508
+ return impl_id
509
+
510
+ for req_id in requirements:
511
+ if req_id.endswith(impl_id) or req_id.endswith(f"-{impl_id}"):
512
+ return req_id
513
+
514
+ return None
@@ -0,0 +1,42 @@
1
+ """
2
+ elspais.mcp - MCP (Model Context Protocol) server for elspais.
3
+
4
+ This module provides an MCP server that exposes elspais functionality
5
+ to AI agents and LLMs. Requires the optional 'mcp' dependency:
6
+
7
+ pip install elspais[mcp]
8
+
9
+ Usage:
10
+ elspais mcp serve # Start with stdio transport
11
+ python -m elspais.mcp # Alternative entry point
12
+ """
13
+
14
+ from elspais.mcp.context import WorkspaceContext
15
+ from elspais.mcp.serializers import (
16
+ serialize_assertion,
17
+ serialize_content_rule,
18
+ serialize_requirement,
19
+ serialize_requirement_summary,
20
+ serialize_violation,
21
+ )
22
+
23
+ __all__ = [
24
+ "WorkspaceContext",
25
+ "serialize_assertion",
26
+ "serialize_content_rule",
27
+ "serialize_requirement",
28
+ "serialize_requirement_summary",
29
+ "serialize_violation",
30
+ ]
31
+
32
+
33
+ def create_server(working_dir=None):
34
+ """Create MCP server instance."""
35
+ from elspais.mcp.server import create_server as _create
36
+ return _create(working_dir)
37
+
38
+
39
+ def run_server(working_dir=None, transport="stdio"):
40
+ """Run MCP server."""
41
+ from elspais.mcp.server import run_server as _run
42
+ return _run(working_dir, transport)
@@ -0,0 +1,6 @@
1
+ """Allow running as: python -m elspais.mcp"""
2
+
3
+ from elspais.mcp.server import run_server
4
+
5
+ if __name__ == "__main__":
6
+ run_server()