elspais 0.11.2__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.
Files changed (147) hide show
  1. elspais/__init__.py +1 -10
  2. elspais/{sponsors/__init__.py → associates.py} +102 -56
  3. elspais/cli.py +366 -69
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +118 -169
  6. elspais/commands/changed.py +12 -23
  7. elspais/commands/config_cmd.py +10 -13
  8. elspais/commands/edit.py +33 -13
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +161 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -115
  13. elspais/commands/init.py +99 -22
  14. elspais/commands/reformat_cmd.py +41 -433
  15. elspais/commands/rules_cmd.py +2 -2
  16. elspais/commands/trace.py +443 -324
  17. elspais/commands/validate.py +193 -411
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -2
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +45 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +1998 -244
  61. elspais/testing/__init__.py +3 -3
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/scanner.py +301 -12
  65. elspais/utilities/__init__.py +1 -0
  66. elspais/utilities/docs_loader.py +115 -0
  67. elspais/utilities/git.py +607 -0
  68. elspais/{core → utilities}/hasher.py +8 -22
  69. elspais/utilities/md_renderer.py +189 -0
  70. elspais/{core → utilities}/patterns.py +56 -51
  71. elspais/utilities/reference_config.py +626 -0
  72. elspais/validation/__init__.py +19 -0
  73. elspais/validation/format.py +264 -0
  74. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  75. elspais-0.43.5.dist-info/RECORD +80 -0
  76. elspais/config/defaults.py +0 -179
  77. elspais/config/loader.py +0 -494
  78. elspais/core/__init__.py +0 -21
  79. elspais/core/git.py +0 -346
  80. elspais/core/models.py +0 -320
  81. elspais/core/parser.py +0 -639
  82. elspais/core/rules.py +0 -509
  83. elspais/mcp/context.py +0 -172
  84. elspais/mcp/serializers.py +0 -112
  85. elspais/reformat/__init__.py +0 -50
  86. elspais/reformat/detector.py +0 -112
  87. elspais/reformat/hierarchy.py +0 -247
  88. elspais/reformat/line_breaks.py +0 -218
  89. elspais/reformat/prompts.py +0 -133
  90. elspais/reformat/transformer.py +0 -266
  91. elspais/trace_view/__init__.py +0 -55
  92. elspais/trace_view/coverage.py +0 -183
  93. elspais/trace_view/generators/__init__.py +0 -12
  94. elspais/trace_view/generators/base.py +0 -334
  95. elspais/trace_view/generators/csv.py +0 -118
  96. elspais/trace_view/generators/markdown.py +0 -170
  97. elspais/trace_view/html/__init__.py +0 -33
  98. elspais/trace_view/html/generator.py +0 -1140
  99. elspais/trace_view/html/templates/base.html +0 -283
  100. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  101. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  102. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  103. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  104. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  105. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  106. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  107. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  108. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  109. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  110. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  111. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  112. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  113. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  114. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  115. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  116. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  117. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  118. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  119. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  120. elspais/trace_view/models.py +0 -378
  121. elspais/trace_view/review/__init__.py +0 -63
  122. elspais/trace_view/review/branches.py +0 -1142
  123. elspais/trace_view/review/models.py +0 -1200
  124. elspais/trace_view/review/position.py +0 -591
  125. elspais/trace_view/review/server.py +0 -1032
  126. elspais/trace_view/review/status.py +0 -455
  127. elspais/trace_view/review/storage.py +0 -1343
  128. elspais/trace_view/scanning.py +0 -213
  129. elspais/trace_view/specs/README.md +0 -84
  130. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  131. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  132. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  133. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  134. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  135. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  136. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  137. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  138. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  139. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  140. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  141. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  142. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  143. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  144. elspais-0.11.2.dist-info/RECORD +0 -101
  145. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  146. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  147. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
@@ -1,112 +0,0 @@
1
- """
2
- elspais.mcp.serializers - JSON serialization for MCP responses.
3
-
4
- Provides functions to serialize elspais data models to JSON-compatible dicts.
5
- """
6
-
7
- from typing import Any, Dict
8
-
9
- from elspais.core.models import Assertion, ContentRule, Requirement
10
- from elspais.core.rules import RuleViolation
11
-
12
-
13
- def serialize_requirement(req: Requirement) -> Dict[str, Any]:
14
- """
15
- Serialize a Requirement to a JSON-compatible dict.
16
-
17
- Args:
18
- req: Requirement to serialize
19
-
20
- Returns:
21
- Dict suitable for JSON serialization
22
- """
23
- return {
24
- "id": req.id,
25
- "title": req.title,
26
- "level": req.level,
27
- "status": req.status,
28
- "body": req.body,
29
- "implements": req.implements,
30
- "assertions": [serialize_assertion(a) for a in req.assertions],
31
- "rationale": req.rationale,
32
- "hash": req.hash,
33
- "file_path": str(req.file_path) if req.file_path else None,
34
- "line_number": req.line_number,
35
- "subdir": req.subdir,
36
- "type_code": req.type_code,
37
- }
38
-
39
-
40
- def serialize_requirement_summary(req: Requirement) -> Dict[str, Any]:
41
- """
42
- Serialize requirement summary (lighter weight, for listings).
43
-
44
- Args:
45
- req: Requirement to serialize
46
-
47
- Returns:
48
- Dict with summary fields only
49
- """
50
- return {
51
- "id": req.id,
52
- "title": req.title,
53
- "level": req.level,
54
- "status": req.status,
55
- "implements": req.implements,
56
- "assertion_count": len(req.assertions),
57
- }
58
-
59
-
60
- def serialize_assertion(assertion: Assertion) -> Dict[str, Any]:
61
- """
62
- Serialize an Assertion to a JSON-compatible dict.
63
-
64
- Args:
65
- assertion: Assertion to serialize
66
-
67
- Returns:
68
- Dict suitable for JSON serialization
69
- """
70
- return {
71
- "label": assertion.label,
72
- "text": assertion.text,
73
- "is_placeholder": assertion.is_placeholder,
74
- }
75
-
76
-
77
- def serialize_violation(violation: RuleViolation) -> Dict[str, Any]:
78
- """
79
- Serialize a RuleViolation to a JSON-compatible dict.
80
-
81
- Args:
82
- violation: RuleViolation to serialize
83
-
84
- Returns:
85
- Dict suitable for JSON serialization
86
- """
87
- return {
88
- "rule_name": violation.rule_name,
89
- "requirement_id": violation.requirement_id,
90
- "message": violation.message,
91
- "severity": violation.severity.value,
92
- "location": violation.location,
93
- }
94
-
95
-
96
- def serialize_content_rule(rule: ContentRule) -> Dict[str, Any]:
97
- """
98
- Serialize a ContentRule to a JSON-compatible dict.
99
-
100
- Args:
101
- rule: ContentRule to serialize
102
-
103
- Returns:
104
- Dict suitable for JSON serialization
105
- """
106
- return {
107
- "file_path": str(rule.file_path),
108
- "title": rule.title,
109
- "content": rule.content,
110
- "type": rule.type,
111
- "applies_to": rule.applies_to,
112
- }
@@ -1,50 +0,0 @@
1
- # Implements: REQ-int-d00008 (Reformat Command)
2
- """
3
- elspais.reformat - Requirement format transformation.
4
-
5
- Transforms legacy Acceptance Criteria format to Assertions format.
6
- Also provides line break normalization.
7
-
8
- IMPLEMENTS REQUIREMENTS:
9
- REQ-int-d00008: Reformat Command
10
- """
11
-
12
- from elspais.reformat.detector import FormatAnalysis, detect_format, needs_reformatting
13
- from elspais.reformat.hierarchy import (
14
- RequirementNode,
15
- build_hierarchy,
16
- get_all_requirements,
17
- normalize_req_id,
18
- traverse_top_down,
19
- )
20
- from elspais.reformat.line_breaks import (
21
- detect_line_break_issues,
22
- fix_requirement_line_breaks,
23
- normalize_line_breaks,
24
- )
25
- from elspais.reformat.transformer import (
26
- assemble_new_format,
27
- reformat_requirement,
28
- validate_reformatted_content,
29
- )
30
-
31
- __all__ = [
32
- # Detection
33
- "detect_format",
34
- "needs_reformatting",
35
- "FormatAnalysis",
36
- # Transformation
37
- "reformat_requirement",
38
- "assemble_new_format",
39
- "validate_reformatted_content",
40
- # Line breaks
41
- "normalize_line_breaks",
42
- "fix_requirement_line_breaks",
43
- "detect_line_break_issues",
44
- # Hierarchy
45
- "RequirementNode",
46
- "get_all_requirements",
47
- "build_hierarchy",
48
- "traverse_top_down",
49
- "normalize_req_id",
50
- ]
@@ -1,112 +0,0 @@
1
- # Implements: REQ-int-d00008 (Reformat Command)
2
- """
3
- Format detection for requirements.
4
-
5
- Detects whether a requirement is in old format (needs reformatting)
6
- or new format (already reformatted).
7
- """
8
-
9
- import re
10
- from dataclasses import dataclass
11
-
12
-
13
- @dataclass
14
- class FormatAnalysis:
15
- """Result of format detection analysis."""
16
-
17
- is_new_format: bool
18
- has_assertions_section: bool
19
- has_labeled_assertions: bool
20
- has_acceptance_criteria: bool
21
- uses_shall_language: bool
22
- assertion_count: int
23
- confidence: float # 0.0 to 1.0
24
-
25
-
26
- def detect_format(body: str, rationale: str = "") -> FormatAnalysis:
27
- """
28
- Detect whether a requirement is in old or new format.
29
-
30
- New format indicators:
31
- - Has '## Assertions' section with labeled assertions (A., B., C.)
32
- - Does NOT have '**Acceptance Criteria**:' section
33
- - Uses prescriptive SHALL language in assertions
34
-
35
- Old format indicators:
36
- - Has '**Acceptance Criteria**:' or 'Acceptance Criteria:' section
37
- - Uses descriptive language (does, has, provides) without labeled assertions
38
- - May have bullet points without letter labels
39
-
40
- Args:
41
- body: The requirement body text
42
- rationale: Optional rationale text
43
-
44
- Returns:
45
- FormatAnalysis with detection results
46
- """
47
- full_text = f"{body}\n{rationale}".strip()
48
-
49
- # Check for ## Assertions section
50
- has_assertions_section = bool(re.search(r"^##\s+Assertions\s*$", full_text, re.MULTILINE))
51
-
52
- # Check for labeled assertions (A., B., C., etc. followed by SHALL somewhere in the line)
53
- labeled_assertions = re.findall(
54
- r"^[A-Z]\.\s+.*\bSHALL\b", full_text, re.MULTILINE | re.IGNORECASE
55
- )
56
- has_labeled_assertions = len(labeled_assertions) >= 1
57
- assertion_count = len(labeled_assertions)
58
-
59
- # Check for Acceptance Criteria section
60
- has_acceptance_criteria = bool(
61
- re.search(r"\*?\*?Acceptance\s+Criteria\*?\*?\s*:", full_text, re.IGNORECASE)
62
- )
63
-
64
- # Check for SHALL language usage anywhere
65
- shall_count = len(re.findall(r"\bSHALL\b", full_text, re.IGNORECASE))
66
- uses_shall_language = shall_count >= 1
67
-
68
- # Determine if new format
69
- # New format: has Assertions section with labeled assertions, no Acceptance Criteria
70
- is_new_format = (
71
- has_assertions_section and has_labeled_assertions and not has_acceptance_criteria
72
- )
73
-
74
- # Calculate confidence score
75
- confidence = 0.0
76
- if has_assertions_section:
77
- confidence += 0.35
78
- if has_labeled_assertions:
79
- confidence += 0.35
80
- if not has_acceptance_criteria:
81
- confidence += 0.20
82
- if uses_shall_language:
83
- confidence += 0.10
84
-
85
- # Invert confidence if old format
86
- if not is_new_format:
87
- confidence = 1.0 - confidence
88
-
89
- return FormatAnalysis(
90
- is_new_format=is_new_format,
91
- has_assertions_section=has_assertions_section,
92
- has_labeled_assertions=has_labeled_assertions,
93
- has_acceptance_criteria=has_acceptance_criteria,
94
- uses_shall_language=uses_shall_language,
95
- assertion_count=assertion_count,
96
- confidence=confidence,
97
- )
98
-
99
-
100
- def needs_reformatting(body: str, rationale: str = "") -> bool:
101
- """
102
- Simple check if a requirement needs reformatting.
103
-
104
- Args:
105
- body: The requirement body text
106
- rationale: Optional rationale text
107
-
108
- Returns:
109
- True if the requirement needs reformatting (is in old format)
110
- """
111
- analysis = detect_format(body, rationale)
112
- return not analysis.is_new_format
@@ -1,247 +0,0 @@
1
- # Implements: REQ-int-d00008 (Reformat Command)
2
- """
3
- Hierarchy traversal logic for requirements.
4
-
5
- Uses elspais core modules directly to parse requirements and build
6
- a traversable hierarchy based on implements relationships.
7
- """
8
-
9
- import sys
10
- from dataclasses import dataclass, field
11
- from pathlib import Path
12
- from typing import TYPE_CHECKING, Callable, Dict, List, Optional
13
-
14
- if TYPE_CHECKING:
15
- from elspais.core.models import Requirement
16
- from elspais.core.patterns import PatternValidator
17
-
18
-
19
- @dataclass
20
- class RequirementNode:
21
- """Represents a requirement with its metadata and hierarchy info."""
22
-
23
- req_id: str
24
- title: str
25
- body: str
26
- rationale: str
27
- file_path: str
28
- line: int
29
- implements: List[str] # Parent REQ IDs
30
- hash: str
31
- status: str
32
- level: str
33
- children: List[str] = field(default_factory=list) # Child REQ IDs
34
-
35
- @classmethod
36
- def from_core(cls, req: "Requirement") -> "RequirementNode":
37
- """
38
- Create a RequirementNode from a core Requirement object.
39
-
40
- Args:
41
- req: Core Requirement object from elspais.core.models
42
-
43
- Returns:
44
- RequirementNode with mapped fields
45
- """
46
- return cls(
47
- req_id=req.id,
48
- title=req.title,
49
- body=req.body,
50
- rationale=req.rationale or "",
51
- file_path=str(req.file_path) if req.file_path else "",
52
- line=req.line_number or 0,
53
- implements=list(req.implements),
54
- hash=req.hash or "",
55
- status=req.status,
56
- level=req.level,
57
- children=[],
58
- )
59
-
60
-
61
- def get_all_requirements(
62
- config_path: Optional[Path] = None,
63
- base_path: Optional[Path] = None,
64
- mode: str = "combined",
65
- ) -> Dict[str, RequirementNode]:
66
- """
67
- Get all requirements using core parser directly.
68
-
69
- Args:
70
- config_path: Optional path to .elspais.toml config file
71
- base_path: Base path for resolving relative directories
72
- mode: Which repos to include:
73
- - "combined" (default): Load local + core/associated repo requirements
74
- - "core-only": Load only core/associated repo requirements
75
- - "local-only": Load only local requirements
76
-
77
- Returns:
78
- Dict mapping requirement ID (e.g., 'REQ-d00027') to RequirementNode
79
- """
80
- from elspais.commands.validate import load_requirements_from_repo
81
- from elspais.config.loader import find_config_file, get_spec_directories, load_config
82
- from elspais.core.parser import RequirementParser
83
- from elspais.core.patterns import PatternConfig
84
-
85
- # Find and load config
86
- if config_path is None:
87
- config_path = find_config_file(base_path or Path.cwd())
88
-
89
- if config_path is None:
90
- print("Warning: No .elspais.toml found", file=sys.stderr)
91
- return {}
92
-
93
- try:
94
- config = load_config(config_path)
95
- except Exception as e:
96
- print(f"Warning: Failed to load config: {e}", file=sys.stderr)
97
- return {}
98
-
99
- requirements = {}
100
-
101
- # Load local requirements (unless core-only mode)
102
- if mode in ("combined", "local-only"):
103
- # Create parser with pattern config
104
- pattern_config = PatternConfig.from_dict(config.get("patterns", {}))
105
- parser = RequirementParser(pattern_config)
106
-
107
- # Get spec directories
108
- spec_dirs = get_spec_directories(None, config, base_path or config_path.parent)
109
-
110
- if spec_dirs:
111
- try:
112
- parse_result = parser.parse_directories(spec_dirs)
113
- for req_id, req in parse_result.requirements.items():
114
- requirements[req_id] = RequirementNode.from_core(req)
115
- except Exception as e:
116
- print(f"Warning: Failed to parse local requirements: {e}", file=sys.stderr)
117
-
118
- # Load core/associated repo requirements (unless local-only mode)
119
- if mode in ("combined", "core-only"):
120
- core_path = config.get("core", {}).get("path")
121
- if core_path:
122
- core_reqs = load_requirements_from_repo(Path(core_path), config)
123
- for req_id, req in core_reqs.items():
124
- # Don't overwrite local requirements with same ID
125
- if req_id not in requirements:
126
- requirements[req_id] = RequirementNode.from_core(req)
127
-
128
- if not requirements:
129
- print("Warning: No requirements found", file=sys.stderr)
130
-
131
- return requirements
132
-
133
-
134
- def build_hierarchy(requirements: Dict[str, RequirementNode]) -> Dict[str, RequirementNode]:
135
- """
136
- Compute children for each requirement by inverting implements relationships.
137
-
138
- This modifies the requirements dict in-place, populating each node's
139
- children list.
140
- """
141
- for req_id, node in requirements.items():
142
- for parent_id in node.implements:
143
- # Normalize parent ID format
144
- parent_key = parent_id if parent_id.startswith("REQ-") else f"REQ-{parent_id}"
145
- if parent_key in requirements:
146
- requirements[parent_key].children.append(req_id)
147
-
148
- # Sort children for deterministic traversal
149
- for node in requirements.values():
150
- node.children.sort()
151
-
152
- return requirements
153
-
154
-
155
- def traverse_top_down(
156
- requirements: Dict[str, RequirementNode],
157
- start_req: str,
158
- max_depth: Optional[int] = None,
159
- callback: Optional[Callable[[RequirementNode, int], None]] = None,
160
- ) -> List[str]:
161
- """
162
- Traverse hierarchy from start_req downward using BFS.
163
-
164
- Args:
165
- requirements: All requirements with children computed
166
- start_req: Starting REQ ID (e.g., 'REQ-p00044')
167
- max_depth: Maximum depth to traverse (None = unlimited)
168
- callback: Function to call for each REQ visited (node, depth)
169
-
170
- Returns:
171
- List of REQ IDs in traversal order
172
- """
173
- visited = []
174
- queue = [(start_req, 0)] # (req_id, depth)
175
- seen = set()
176
-
177
- while queue:
178
- req_id, depth = queue.pop(0)
179
-
180
- if req_id in seen:
181
- continue
182
-
183
- # Depth limit check (depth 0 is the start node)
184
- if max_depth is not None and depth > max_depth:
185
- continue
186
-
187
- seen.add(req_id)
188
-
189
- if req_id not in requirements:
190
- print(f"Warning: {req_id} not found in requirements", file=sys.stderr)
191
- continue
192
-
193
- visited.append(req_id)
194
- node = requirements[req_id]
195
-
196
- if callback:
197
- callback(node, depth)
198
-
199
- # Add children to queue
200
- for child_id in node.children:
201
- if child_id not in seen:
202
- queue.append((child_id, depth + 1))
203
-
204
- return visited
205
-
206
-
207
- def normalize_req_id(req_id: str, validator: Optional["PatternValidator"] = None) -> str:
208
- """
209
- Normalize requirement ID to canonical format using PatternValidator.
210
-
211
- Args:
212
- req_id: Requirement ID (e.g., "d00027", "REQ-d00027", "REQ-CAL-p00001")
213
- validator: PatternValidator instance (created from config if not provided)
214
-
215
- Returns:
216
- Normalized ID in canonical format from config
217
- """
218
- from elspais.config.loader import find_config_file, load_config
219
- from elspais.core.patterns import PatternConfig, PatternValidator
220
-
221
- # Create validator if not provided
222
- if validator is None:
223
- try:
224
- config_path = find_config_file(Path.cwd())
225
- config = load_config(config_path) if config_path else {}
226
- except Exception:
227
- config = {}
228
- pattern_config = PatternConfig.from_dict(config.get("patterns", {}))
229
- validator = PatternValidator(pattern_config)
230
-
231
- # Try parsing the ID as-is
232
- parsed = validator.parse(req_id)
233
-
234
- # If that fails, try with prefix
235
- if parsed is None and not req_id.upper().startswith(validator.config.prefix):
236
- parsed = validator.parse(f"{validator.config.prefix}-{req_id}")
237
-
238
- if parsed:
239
- # Reconstruct canonical ID from parsed components
240
- parts = [parsed.prefix]
241
- if parsed.associated:
242
- parts.append(parsed.associated)
243
- parts.append(f"{parsed.type_code}{parsed.number}")
244
- return "-".join(parts)
245
-
246
- # Return as-is if unparseable
247
- return req_id