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,217 +1,166 @@
1
+ # Implements: REQ-int-d00003 (CLI Extension)
1
2
  """
2
3
  elspais.commands.analyze - Analyze requirements command.
4
+
5
+ Uses graph-based system for analysis:
6
+ - `elspais analyze hierarchy` - Display requirement hierarchy tree
7
+ - `elspais analyze orphans` - Find orphaned requirements
8
+ - `elspais analyze coverage` - Show implementation coverage report
3
9
  """
4
10
 
11
+ from __future__ import annotations
12
+
5
13
  import argparse
6
14
  import sys
7
- from pathlib import Path
8
- from typing import Dict, List, Optional
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from elspais.graph.builder import TraceGraph
9
19
 
10
- from elspais.config.defaults import DEFAULT_CONFIG
11
- from elspais.config.loader import find_config_file, get_spec_directories, load_config
12
- from elspais.core.models import Requirement
13
- from elspais.core.parser import RequirementParser
14
- from elspais.core.patterns import PatternConfig
20
+ from elspais.graph import GraphNode, NodeKind
15
21
 
16
22
 
17
23
  def run(args: argparse.Namespace) -> int:
18
24
  """Run the analyze command."""
19
- if not args.analyze_action:
20
- print("Usage: elspais analyze {hierarchy|orphans|coverage}")
21
- return 1
25
+ from elspais.graph.factory import build_graph
22
26
 
23
- if args.analyze_action == "hierarchy":
24
- return run_hierarchy(args)
25
- elif args.analyze_action == "orphans":
26
- return run_orphans(args)
27
- elif args.analyze_action == "coverage":
28
- return run_coverage(args)
27
+ spec_dir = getattr(args, "spec_dir", None)
28
+ config_path = getattr(args, "config", None)
29
29
 
30
- return 1
30
+ graph = build_graph(
31
+ spec_dirs=[spec_dir] if spec_dir else None,
32
+ config_path=config_path,
33
+ )
31
34
 
35
+ action = getattr(args, "analyze_action", None)
32
36
 
33
- def run_hierarchy(args: argparse.Namespace) -> int:
34
- """Show requirement hierarchy tree."""
35
- requirements = load_requirements(args)
36
- if not requirements:
37
+ if action == "hierarchy":
38
+ return _analyze_hierarchy(graph, args)
39
+ elif action == "orphans":
40
+ return _analyze_orphans(graph, args)
41
+ elif action == "coverage":
42
+ return _analyze_coverage(graph, args)
43
+ else:
44
+ print("Usage: elspais analyze <hierarchy|orphans|coverage>", file=sys.stderr)
37
45
  return 1
38
46
 
47
+
48
+ def _analyze_hierarchy(graph: TraceGraph, args: argparse.Namespace) -> int:
49
+ """Display requirement hierarchy tree."""
39
50
  print("Requirement Hierarchy")
40
51
  print("=" * 60)
41
52
 
42
- # Find root requirements (PRD with no implements)
43
- roots = [
44
- req
45
- for req in requirements.values()
46
- if req.level.upper() in ["PRD", "PRODUCT"] and not req.implements
47
- ]
48
-
49
- if not roots:
50
- # Fall back to all PRD requirements
51
- roots = [req for req in requirements.values() if req.level.upper() in ["PRD", "PRODUCT"]]
53
+ # Find root requirements (PRD level or no parents)
54
+ roots = []
55
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
56
+ level = (node.level or "").upper()
57
+ if level == "PRD" or node.parent_count() == 0:
58
+ # Check if this has no parent requirements
59
+ has_req_parent = False
60
+ for parent in node.iter_parents():
61
+ if parent.kind == NodeKind.REQUIREMENT:
62
+ has_req_parent = True
63
+ break
64
+ if not has_req_parent:
65
+ roots.append(node)
52
66
 
53
- printed = set()
67
+ for root in sorted(roots, key=lambda n: n.id):
68
+ _print_tree(root, indent=0)
54
69
 
55
- def print_tree(req: Requirement, indent: int = 0) -> None:
56
- if req.id in printed:
57
- return
58
- printed.add(req.id)
70
+ return 0
59
71
 
60
- prefix = " " * indent
61
- status_icon = "✓" if req.status == "Active" else "○"
62
- print(f"{prefix}{status_icon} {req.id}: {req.title}")
63
72
 
64
- # Find children (requirements that implement this one)
65
- children = find_children(req.id, requirements)
66
- for child in children:
67
- print_tree(child, indent + 1)
73
+ def _print_tree(node: GraphNode, indent: int) -> None:
74
+ """Recursively print node and children."""
75
+ prefix = " " * indent
76
+ status_icon = "[x]" if (node.status or "").lower() == "active" else "[ ]"
77
+ level = node.level or "?"
78
+ print(f"{prefix}{status_icon} {node.id} ({level}) - {node.get_label()}")
68
79
 
69
- for root in sorted(roots, key=lambda r: r.id):
70
- print_tree(root)
71
- print()
80
+ # Get child requirements
81
+ children = []
82
+ for child in node.iter_children():
83
+ if child.kind == NodeKind.REQUIREMENT:
84
+ children.append(child)
72
85
 
73
- return 0
86
+ for child in sorted(children, key=lambda n: n.id):
87
+ _print_tree(child, indent + 1)
74
88
 
75
89
 
76
- def run_orphans(args: argparse.Namespace) -> int:
90
+ def _analyze_orphans(graph: TraceGraph, args: argparse.Namespace) -> int:
77
91
  """Find orphaned requirements."""
78
- requirements = load_requirements(args)
79
- if not requirements:
80
- return 1
81
-
82
92
  orphans = []
83
93
 
84
- for req in requirements.values():
85
- # Skip PRD (they can be roots)
86
- if req.level.upper() in ["PRD", "PRODUCT"]:
94
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
95
+ level = (node.level or "").upper()
96
+ # PRD level should not have parents
97
+ if level == "PRD":
87
98
  continue
88
99
 
89
- # Check if this requirement implements anything
90
- if not req.implements:
91
- orphans.append(req)
92
- else:
93
- # Check if all implements references are valid
94
- all_valid = True
95
- for impl_id in req.implements:
96
- if not find_requirement(impl_id, requirements):
97
- all_valid = False
98
- break
99
- if not all_valid:
100
- orphans.append(req)
101
-
102
- if orphans:
103
- print(f"Orphaned Requirements ({len(orphans)}):")
104
- print("-" * 40)
105
- for req in sorted(orphans, key=lambda r: r.id):
106
- impl_str = ", ".join(req.implements) if req.implements else "(none)"
107
- print(f" {req.id}: {req.title}")
108
- print(f" Level: {req.level} | Implements: {impl_str}")
109
- if req.file_path:
110
- print(f" File: {req.file_path.name}:{req.line_number}")
111
- print()
112
- else:
113
- print("✓ No orphaned requirements found")
114
-
115
- return 0
116
-
100
+ # Check for parent requirements
101
+ has_req_parent = False
102
+ for parent in node.iter_parents():
103
+ if parent.kind == NodeKind.REQUIREMENT:
104
+ has_req_parent = True
105
+ break
117
106
 
118
- def run_coverage(args: argparse.Namespace) -> int:
119
- """Show implementation coverage report."""
120
- requirements = load_requirements(args)
121
- if not requirements:
122
- return 1
107
+ if not has_req_parent:
108
+ orphans.append(node)
123
109
 
124
- # Group by type
125
- prd_count = sum(1 for r in requirements.values() if r.level.upper() in ["PRD", "PRODUCT"])
126
- ops_count = sum(1 for r in requirements.values() if r.level.upper() in ["OPS", "OPERATIONS"])
127
- dev_count = sum(1 for r in requirements.values() if r.level.upper() in ["DEV", "DEVELOPMENT"])
128
-
129
- # Count PRD requirements that have implementations
130
- implemented_prd = set()
131
- for req in requirements.values():
132
- for impl_id in req.implements:
133
- # Resolve to full ID
134
- target = find_requirement(impl_id, requirements)
135
- if target and target.level.upper() in ["PRD", "PRODUCT"]:
136
- implemented_prd.add(target.id)
137
-
138
- print("Implementation Coverage Report")
139
- print("=" * 60)
140
- print()
141
- print(f"Total Requirements: {len(requirements)}")
142
- print(f" PRD: {prd_count}")
143
- print(f" OPS: {ops_count}")
144
- print(f" DEV: {dev_count}")
145
- print()
146
- print("PRD Implementation Coverage:")
147
- print(f" Implemented: {len(implemented_prd)}/{prd_count}")
148
- if prd_count > 0:
149
- pct = (len(implemented_prd) / prd_count) * 100
150
- print(f" Coverage: {pct:.1f}%")
151
-
152
- # List unimplemented PRD
153
- unimplemented = [
154
- req
155
- for req in requirements.values()
156
- if req.level.upper() in ["PRD", "PRODUCT"] and req.id not in implemented_prd
157
- ]
158
-
159
- if unimplemented:
110
+ if orphans:
111
+ print(f"Found {len(orphans)} orphaned requirements:")
160
112
  print()
161
- print(f"Unimplemented PRD ({len(unimplemented)}):")
162
- for req in sorted(unimplemented, key=lambda r: r.id):
163
- print(f" - {req.id}: {req.title}")
164
-
165
- return 0
166
-
167
-
168
- def load_requirements(args: argparse.Namespace) -> Dict[str, Requirement]:
169
- """Load requirements from spec directories."""
170
- config_path = args.config or find_config_file(Path.cwd())
171
- if config_path and config_path.exists():
172
- config = load_config(config_path)
113
+ for node in sorted(orphans, key=lambda n: n.id):
114
+ loc = f"{node.source.path}:{node.source.line}" if node.source else "unknown"
115
+ print(f" {node.id} ({node.level or '?'}) - {node.get_label()}")
116
+ print(f" Location: {loc}")
173
117
  else:
174
- config = DEFAULT_CONFIG
118
+ print("No orphaned requirements found.")
175
119
 
176
- spec_dirs = get_spec_directories(args.spec_dir, config)
177
- if not spec_dirs:
178
- print("Error: No spec directories found", file=sys.stderr)
179
- return {}
120
+ return 1 if orphans else 0
180
121
 
181
- pattern_config = PatternConfig.from_dict(config.get("patterns", {}))
182
- spec_config = config.get("spec", {})
183
- no_reference_values = spec_config.get("no_reference_values")
184
- skip_files = spec_config.get("skip_files", [])
185
- parser = RequirementParser(pattern_config, no_reference_values=no_reference_values)
186
122
 
187
- try:
188
- return parser.parse_directories(spec_dirs, skip_files=skip_files)
189
- except Exception as e:
190
- print(f"Error parsing requirements: {e}", file=sys.stderr)
191
- return {}
123
+ def _analyze_coverage(graph: TraceGraph, args: argparse.Namespace) -> int:
124
+ """Show implementation coverage report."""
125
+ from elspais.graph.annotators import group_by_level
192
126
 
127
+ # Group requirements by level using shared utility
128
+ by_level = group_by_level(graph)
193
129
 
194
- def find_children(req_id: str, requirements: Dict[str, Requirement]) -> List[Requirement]:
195
- """Find requirements that implement the given requirement."""
196
- children = []
197
- short_id = req_id.split("-")[-1] if "-" in req_id else req_id
130
+ print("Requirements by Level")
131
+ print("=" * 40)
132
+ for level, nodes in by_level.items():
133
+ if nodes:
134
+ print(f" {level}: {len(nodes)}")
198
135
 
199
- for other_req in requirements.values():
200
- for impl in other_req.implements:
201
- if impl == req_id or impl == short_id or impl.endswith(short_id):
202
- children.append(other_req)
203
- break
136
+ # PRD implementation coverage
137
+ prd_nodes = by_level["PRD"]
138
+ if prd_nodes:
139
+ print()
140
+ print("PRD Implementation Coverage")
141
+ print("-" * 40)
204
142
 
205
- return sorted(children, key=lambda r: r.id)
143
+ implemented = []
144
+ unimplemented = []
206
145
 
146
+ for prd in prd_nodes:
147
+ has_children = False
148
+ for child in prd.iter_children():
149
+ if child.kind == NodeKind.REQUIREMENT:
150
+ has_children = True
151
+ break
152
+ if has_children:
153
+ implemented.append(prd)
154
+ else:
155
+ unimplemented.append(prd)
207
156
 
208
- def find_requirement(impl_id: str, requirements: Dict[str, Requirement]) -> Optional[Requirement]:
209
- """Find a requirement by full or partial ID."""
210
- if impl_id in requirements:
211
- return requirements[impl_id]
157
+ pct = len(implemented) / len(prd_nodes) * 100 if prd_nodes else 0
158
+ print(f" Implemented: {len(implemented)}/{len(prd_nodes)} ({pct:.1f}%)")
212
159
 
213
- for req_id, req in requirements.items():
214
- if req_id.endswith(impl_id) or req_id.endswith(f"-{impl_id}"):
215
- return req
160
+ if unimplemented:
161
+ print()
162
+ print(" Unimplemented PRD requirements:")
163
+ for node in sorted(unimplemented, key=lambda n: n.id):
164
+ print(f" {node.id} - {node.get_label()}")
216
165
 
217
- return None
166
+ return 0
@@ -9,37 +9,26 @@ Detects changes to requirement files using git:
9
9
 
10
10
  import argparse
11
11
  import json
12
- import sys
13
- from pathlib import Path
14
12
  from typing import Dict, Optional
15
13
 
16
- from elspais.config.defaults import DEFAULT_CONFIG
17
- from elspais.config.loader import find_config_file, load_config
18
- from elspais.core.git import (
14
+ from elspais.utilities.git import (
19
15
  detect_moved_requirements,
20
16
  filter_spec_files,
21
- get_current_req_locations,
22
17
  get_git_changes,
23
18
  get_repo_root,
19
+ get_req_locations_from_graph,
24
20
  )
25
21
 
26
22
 
27
23
  def load_configuration(args: argparse.Namespace) -> Optional[Dict]:
28
- """Load configuration from file or use defaults."""
29
- config_path = getattr(args, "config", None)
30
- if config_path:
31
- pass # Use provided path
32
- else:
33
- config_path = find_config_file(Path.cwd())
34
-
35
- if config_path and config_path.exists():
36
- try:
37
- return load_config(config_path)
38
- except Exception as e:
39
- print(f"Error loading config: {e}", file=sys.stderr)
40
- return None
41
- else:
42
- return DEFAULT_CONFIG
24
+ """Load configuration from file or use defaults.
25
+
26
+ Note: This is a wrapper for get_config() that returns Optional[Dict]
27
+ for backward compatibility. New code should use get_config() directly.
28
+ """
29
+ from elspais.config import get_config
30
+
31
+ return get_config(config_path=getattr(args, "config", None))
43
32
 
44
33
 
45
34
  def run(args: argparse.Namespace) -> int:
@@ -72,8 +61,8 @@ def run(args: argparse.Namespace) -> int:
72
61
  spec_untracked = filter_spec_files(changes.untracked_files, spec_dir)
73
62
  spec_branch = filter_spec_files(changes.branch_changed_files, spec_dir)
74
63
 
75
- # Detect moved requirements
76
- current_locations = get_current_req_locations(repo_root, spec_dir)
64
+ # Detect moved requirements using graph-based approach
65
+ current_locations = get_req_locations_from_graph(repo_root)
77
66
  moved = detect_moved_requirements(changes.committed_req_locations, current_locations)
78
67
 
79
68
  # Build result
@@ -10,12 +10,7 @@ import sys
10
10
  from pathlib import Path
11
11
  from typing import Any, Dict, List, Optional, Tuple
12
12
 
13
- from elspais.config.defaults import DEFAULT_CONFIG
14
- from elspais.config.loader import (
15
- find_config_file,
16
- load_config,
17
- parse_toml,
18
- )
13
+ from elspais.config import DEFAULT_CONFIG, find_config_file, load_config, parse_toml
19
14
 
20
15
 
21
16
  def run(args: argparse.Namespace) -> int:
@@ -57,7 +52,7 @@ def cmd_show(args: argparse.Namespace) -> int:
57
52
  print("No configuration file found. Run 'elspais init' to create one.")
58
53
  return 1
59
54
 
60
- config = load_config(config_path)
55
+ config = load_config(config_path).get_raw()
61
56
  section = getattr(args, "section", None)
62
57
 
63
58
  if section:
@@ -83,14 +78,16 @@ def cmd_get(args: argparse.Namespace) -> int:
83
78
  print("No configuration file found.", file=sys.stderr)
84
79
  return 1
85
80
 
86
- config = load_config(config_path)
81
+ config_loader = load_config(config_path)
87
82
  key = args.key
88
83
 
89
- value = _get_by_path(config, key)
84
+ # Use ConfigLoader.get() for dot-notation access
85
+ value = config_loader.get(key)
90
86
  if value is None:
91
- # Check if it's truly None vs not found
87
+ # Check if it's truly None vs not found using raw dict
88
+ raw = config_loader.get_raw()
92
89
  parts = key.split(".")
93
- current = config
90
+ current = raw
94
91
  for part in parts[:-1]:
95
92
  if part not in current:
96
93
  print(f"Key not found: {key}", file=sys.stderr)
@@ -214,8 +211,8 @@ def cmd_remove(args: argparse.Namespace) -> int:
214
211
  user_config = _load_user_config(config_path)
215
212
  merged_config = load_config(config_path)
216
213
 
217
- # Get current merged value to see what's there
218
- current = _get_by_path(merged_config, key)
214
+ # Get current merged value using ConfigLoader.get() for dot-notation access
215
+ current = merged_config.get(key)
219
216
 
220
217
  if current is None or not isinstance(current, list):
221
218
  print(f"Error: {key} is not an array or doesn't exist", file=sys.stderr)
elspais/commands/edit.py CHANGED
@@ -18,8 +18,7 @@ from typing import Any, Dict, List, Optional
18
18
 
19
19
  def run(args: argparse.Namespace) -> int:
20
20
  """Run the edit command."""
21
- from elspais.config.defaults import DEFAULT_CONFIG
22
- from elspais.config.loader import find_config_file, get_spec_directories, load_config
21
+ from elspais.config import DEFAULT_CONFIG, find_config_file, get_spec_directories, load_config
23
22
 
24
23
  # Load configuration
25
24
  config_path = args.config if hasattr(args, "config") else None
@@ -50,7 +49,7 @@ def run(args: argparse.Namespace) -> int:
50
49
 
51
50
  # Handle single edit mode
52
51
  if hasattr(args, "req_id") and args.req_id:
53
- return run_single_edit(args, base_spec_dir, dry_run)
52
+ return run_single_edit(args, base_spec_dir, dry_run, validate_refs)
54
53
 
55
54
  print("Error: Must specify --req-id or --from-json", file=sys.stderr)
56
55
  return 1
@@ -95,7 +94,9 @@ def run_batch_edit(
95
94
  return 0
96
95
 
97
96
 
98
- def run_single_edit(args: argparse.Namespace, spec_dir: Path, dry_run: bool) -> int:
97
+ def run_single_edit(
98
+ args: argparse.Namespace, spec_dir: Path, dry_run: bool, validate_refs: bool = False
99
+ ) -> int:
99
100
  """Run single requirement edit."""
100
101
  req_id = args.req_id
101
102
 
@@ -108,9 +109,28 @@ def run_single_edit(args: argparse.Namespace, spec_dir: Path, dry_run: bool) ->
108
109
  file_path = location["file_path"]
109
110
  results = []
110
111
 
112
+ # Collect valid refs if validation is enabled
113
+ valid_refs: Optional[set] = None
114
+ if validate_refs:
115
+ valid_refs = collect_all_req_ids(spec_dir)
116
+
111
117
  # Apply implements change
112
118
  if hasattr(args, "implements") and args.implements is not None:
113
119
  impl_list = [i.strip() for i in args.implements.split(",")]
120
+
121
+ # Validate references if enabled
122
+ if validate_refs and valid_refs:
123
+ invalid_refs = []
124
+ for ref in impl_list:
125
+ if ref not in valid_refs and f"REQ-{ref}" not in valid_refs:
126
+ invalid_refs.append(ref)
127
+ if invalid_refs:
128
+ print(
129
+ f"Error: Invalid implements references: {', '.join(invalid_refs)}",
130
+ file=sys.stderr,
131
+ )
132
+ return 1
133
+
114
134
  result = modify_implements(file_path, req_id, impl_list, dry_run=dry_run)
115
135
  results.append(("implements", result))
116
136
 
@@ -156,8 +176,8 @@ def find_requirement_in_files(
156
176
  Returns:
157
177
  Dict with file_path, req_id, line_number, or None if not found
158
178
  """
159
- # Pattern to match requirement header
160
- pattern = re.compile(rf"^#\s*{re.escape(req_id)}:", re.MULTILINE)
179
+ # Pattern to match requirement header (any markdown header level ##, ###, etc.)
180
+ pattern = re.compile(rf"^#+\s*{re.escape(req_id)}:", re.MULTILINE)
161
181
 
162
182
  for md_file in spec_dir.rglob("*.md"):
163
183
  content = md_file.read_text()
@@ -194,8 +214,8 @@ def modify_implements(
194
214
  """
195
215
  content = file_path.read_text()
196
216
 
197
- # Find the requirement header
198
- req_pattern = re.compile(rf"^(#\s*{re.escape(req_id)}:[^\n]*\n)", re.MULTILINE)
217
+ # Find the requirement header (any markdown header level)
218
+ req_pattern = re.compile(rf"^(#+\s*{re.escape(req_id)}:[^\n]*\n)", re.MULTILINE)
199
219
  req_match = req_pattern.search(content)
200
220
 
201
221
  if not req_match:
@@ -271,8 +291,8 @@ def modify_status(
271
291
  """
272
292
  content = file_path.read_text()
273
293
 
274
- # Find the requirement header
275
- req_pattern = re.compile(rf"^(#\s*{re.escape(req_id)}:[^\n]*\n)", re.MULTILINE)
294
+ # Find the requirement header (any markdown header level)
295
+ req_pattern = re.compile(rf"^(#+\s*{re.escape(req_id)}:[^\n]*\n)", re.MULTILINE)
276
296
  req_match = req_pattern.search(content)
277
297
 
278
298
  if not req_match:
@@ -340,9 +360,9 @@ def move_requirement(
340
360
  source_content = source_file.read_text()
341
361
 
342
362
  # Find the requirement block
343
- # Pattern: # REQ-xxx: title ... *End* *title* | **Hash**: xxx\n---
363
+ # Pattern: ## REQ-xxx: title ... *End* *title* | **Hash**: xxx\n---
344
364
  req_pattern = re.compile(
345
- rf"(^#\s*{re.escape(req_id)}:[^\n]*\n" rf".*?" rf"\*End\*[^\n]*\n" rf"(?:---\n)?)",
365
+ rf"(^#+\s*{re.escape(req_id)}:[^\n]*\n" rf".*?" rf"\*End\*[^\n]*\n" rf"(?:---\n)?)",
346
366
  re.MULTILINE | re.DOTALL,
347
367
  )
348
368
 
@@ -399,7 +419,7 @@ def collect_all_req_ids(spec_dir: Path) -> set:
399
419
  import re
400
420
 
401
421
  req_ids = set()
402
- pattern = re.compile(r"^#\s*(REQ-[A-Za-z0-9-]+):", re.MULTILINE)
422
+ pattern = re.compile(r"^#+\s*(REQ-[A-Za-z0-9-]+):", re.MULTILINE)
403
423
 
404
424
  for md_file in spec_dir.rglob("*.md"):
405
425
  content = md_file.read_text()