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.
- elspais/__init__.py +1 -10
- elspais/{sponsors/__init__.py → associates.py} +102 -56
- elspais/cli.py +366 -69
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +118 -169
- elspais/commands/changed.py +12 -23
- elspais/commands/config_cmd.py +10 -13
- elspais/commands/edit.py +33 -13
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +161 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -115
- elspais/commands/init.py +99 -22
- elspais/commands/reformat_cmd.py +41 -433
- elspais/commands/rules_cmd.py +2 -2
- elspais/commands/trace.py +443 -324
- elspais/commands/validate.py +193 -411
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -2
- elspais/docs/cli/assertions.md +67 -0
- elspais/docs/cli/commands.md +304 -0
- elspais/docs/cli/config.md +262 -0
- elspais/docs/cli/format.md +66 -0
- elspais/docs/cli/git.md +45 -0
- elspais/docs/cli/health.md +190 -0
- elspais/docs/cli/hierarchy.md +60 -0
- elspais/docs/cli/ignore.md +72 -0
- elspais/docs/cli/mcp.md +245 -0
- elspais/docs/cli/quickstart.md +58 -0
- elspais/docs/cli/traceability.md +89 -0
- elspais/docs/cli/validation.md +96 -0
- elspais/graph/GraphNode.py +383 -0
- elspais/graph/__init__.py +40 -0
- elspais/graph/annotators.py +927 -0
- elspais/graph/builder.py +1886 -0
- elspais/graph/deserializer.py +248 -0
- elspais/graph/factory.py +284 -0
- elspais/graph/metrics.py +127 -0
- elspais/graph/mutations.py +161 -0
- elspais/graph/parsers/__init__.py +156 -0
- elspais/graph/parsers/code.py +213 -0
- elspais/graph/parsers/comments.py +112 -0
- elspais/graph/parsers/config_helpers.py +29 -0
- elspais/graph/parsers/heredocs.py +225 -0
- elspais/graph/parsers/journey.py +131 -0
- elspais/graph/parsers/remainder.py +79 -0
- elspais/graph/parsers/requirement.py +347 -0
- elspais/graph/parsers/results/__init__.py +6 -0
- elspais/graph/parsers/results/junit_xml.py +229 -0
- elspais/graph/parsers/results/pytest_json.py +313 -0
- elspais/graph/parsers/test.py +305 -0
- elspais/graph/relations.py +78 -0
- elspais/graph/serialize.py +216 -0
- elspais/html/__init__.py +8 -0
- elspais/html/generator.py +731 -0
- elspais/html/templates/trace_view.html.j2 +2151 -0
- elspais/mcp/__init__.py +45 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +1998 -244
- elspais/testing/__init__.py +3 -3
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/scanner.py +301 -12
- elspais/utilities/__init__.py +1 -0
- elspais/utilities/docs_loader.py +115 -0
- elspais/utilities/git.py +607 -0
- elspais/{core → utilities}/hasher.py +8 -22
- elspais/utilities/md_renderer.py +189 -0
- elspais/{core → utilities}/patterns.py +56 -51
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
- elspais-0.43.5.dist-info/RECORD +80 -0
- elspais/config/defaults.py +0 -179
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -346
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -639
- elspais/core/rules.py +0 -509
- elspais/mcp/context.py +0 -172
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -112
- elspais/reformat/hierarchy.py +0 -247
- elspais/reformat/line_breaks.py +0 -218
- elspais/reformat/prompts.py +0 -133
- elspais/reformat/transformer.py +0 -266
- elspais/trace_view/__init__.py +0 -55
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -334
- elspais/trace_view/generators/csv.py +0 -118
- elspais/trace_view/generators/markdown.py +0 -170
- elspais/trace_view/html/__init__.py +0 -33
- elspais/trace_view/html/generator.py +0 -1140
- elspais/trace_view/html/templates/base.html +0 -283
- elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
- elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
- elspais/trace_view/html/templates/components/legend_modal.html +0 -69
- elspais/trace_view/html/templates/components/review_panel.html +0 -118
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
- elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
- elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
- elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
- elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
- elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
- elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
- elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
- elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
- elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
- elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
- elspais/trace_view/html/templates/partials/scripts.js +0 -1741
- elspais/trace_view/html/templates/partials/styles.css +0 -1756
- elspais/trace_view/models.py +0 -378
- elspais/trace_view/review/__init__.py +0 -63
- elspais/trace_view/review/branches.py +0 -1142
- elspais/trace_view/review/models.py +0 -1200
- elspais/trace_view/review/position.py +0 -591
- elspais/trace_view/review/server.py +0 -1032
- elspais/trace_view/review/status.py +0 -455
- elspais/trace_view/review/storage.py +0 -1343
- elspais/trace_view/scanning.py +0 -213
- elspais/trace_view/specs/README.md +0 -84
- elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
- elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
- elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
- elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
- elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
- elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
- elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
- elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
- elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
- elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
- elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
- elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
- elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
- elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
- elspais-0.11.2.dist-info/RECORD +0 -101
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
elspais/mcp/server.py
CHANGED
|
@@ -1,354 +1,2108 @@
|
|
|
1
|
+
"""elspais.mcp.server - MCP server implementation.
|
|
2
|
+
|
|
3
|
+
Creates and runs the MCP server exposing elspais functionality.
|
|
4
|
+
|
|
5
|
+
This is a pure interface layer - it consumes TraceGraph directly
|
|
6
|
+
without creating intermediate data structures (REQ-p00060-B).
|
|
1
7
|
"""
|
|
2
|
-
elspais.mcp.server - MCP server implementation.
|
|
3
8
|
|
|
4
|
-
|
|
5
|
-
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
|
|
18
|
+
MCP_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
MCP_AVAILABLE = False
|
|
21
|
+
FastMCP = None
|
|
22
|
+
|
|
23
|
+
from elspais.config import find_config_file, get_config
|
|
24
|
+
from elspais.graph import NodeKind
|
|
25
|
+
from elspais.graph.annotators import count_by_coverage, count_by_git_status, count_by_level
|
|
26
|
+
from elspais.graph.builder import TraceGraph
|
|
27
|
+
from elspais.graph.factory import build_graph
|
|
28
|
+
from elspais.graph.mutations import MutationEntry
|
|
29
|
+
from elspais.graph.relations import EdgeKind
|
|
30
|
+
|
|
31
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
# Serializers (REQ-d00064)
|
|
33
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _serialize_requirement_summary(node: Any) -> dict[str, Any]:
|
|
37
|
+
"""Serialize a requirement node to summary format.
|
|
38
|
+
|
|
39
|
+
REQ-d00064-A: Returns id, title, level, status only.
|
|
40
|
+
REQ-d00064-C: Reads from node.get_field() and node.get_label().
|
|
41
|
+
"""
|
|
42
|
+
return {
|
|
43
|
+
"id": node.id,
|
|
44
|
+
"title": node.get_label(),
|
|
45
|
+
"level": node.get_field("level"),
|
|
46
|
+
"status": node.get_field("status"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _serialize_assertion(node: Any) -> dict[str, Any]:
|
|
51
|
+
"""Serialize an assertion node."""
|
|
52
|
+
return {
|
|
53
|
+
"id": node.id,
|
|
54
|
+
"label": node.get_field("label"),
|
|
55
|
+
"text": node.get_label(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _serialize_requirement_full(node: Any) -> dict[str, Any]:
|
|
60
|
+
"""Serialize a requirement node to full format.
|
|
61
|
+
|
|
62
|
+
REQ-d00064-B: Returns all fields including assertions and edges.
|
|
63
|
+
REQ-d00064-C: Reads from node.get_field() and node.get_label().
|
|
64
|
+
"""
|
|
65
|
+
# Get assertions from children
|
|
66
|
+
assertions = []
|
|
67
|
+
children = []
|
|
68
|
+
for child in node.iter_children():
|
|
69
|
+
if child.kind == NodeKind.ASSERTION:
|
|
70
|
+
assertions.append(_serialize_assertion(child))
|
|
71
|
+
else:
|
|
72
|
+
children.append(_serialize_requirement_summary(child))
|
|
73
|
+
|
|
74
|
+
# Get parents
|
|
75
|
+
parents = []
|
|
76
|
+
for parent in node.iter_parents():
|
|
77
|
+
if parent.kind == NodeKind.REQUIREMENT:
|
|
78
|
+
parents.append(_serialize_requirement_summary(parent))
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"id": node.id,
|
|
82
|
+
"title": node.get_label(),
|
|
83
|
+
"level": node.get_field("level"),
|
|
84
|
+
"status": node.get_field("status"),
|
|
85
|
+
"hash": node.get_field("hash"),
|
|
86
|
+
"assertions": assertions,
|
|
87
|
+
"children": children,
|
|
88
|
+
"parents": parents,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _serialize_mutation_entry(entry: MutationEntry) -> dict[str, Any]:
|
|
93
|
+
"""Serialize a MutationEntry to dict format.
|
|
94
|
+
|
|
95
|
+
REQ-o00062-E: Returns MutationEntry for audit trail.
|
|
96
|
+
"""
|
|
97
|
+
return {
|
|
98
|
+
"id": entry.id,
|
|
99
|
+
"operation": entry.operation,
|
|
100
|
+
"target_id": entry.target_id,
|
|
101
|
+
"before_state": entry.before_state,
|
|
102
|
+
"after_state": entry.after_state,
|
|
103
|
+
"affects_hash": entry.affects_hash,
|
|
104
|
+
"timestamp": entry.timestamp.isoformat(),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _serialize_broken_reference(ref: Any) -> dict[str, Any]:
|
|
109
|
+
"""Serialize a BrokenReference to dict format."""
|
|
110
|
+
return {
|
|
111
|
+
"source_id": ref.source_id,
|
|
112
|
+
"target_id": ref.target_id,
|
|
113
|
+
"edge_kind": str(ref.edge_kind) if hasattr(ref.edge_kind, "value") else ref.edge_kind,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
# Core Tool Functions (REQ-o00060)
|
|
119
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_graph_status(graph: TraceGraph) -> dict[str, Any]:
|
|
123
|
+
"""Get graph status.
|
|
124
|
+
|
|
125
|
+
REQ-d00060-A: Returns is_stale from metadata.
|
|
126
|
+
REQ-d00060-B: Returns node_counts by calling nodes_by_kind().
|
|
127
|
+
REQ-d00060-D: Returns root_count using graph.root_count().
|
|
128
|
+
REQ-d00060-E: Does NOT iterate full graph for counts.
|
|
129
|
+
"""
|
|
130
|
+
# Count nodes by kind using the efficient nodes_by_kind iterator
|
|
131
|
+
node_counts: dict[str, int] = {}
|
|
132
|
+
for kind in NodeKind:
|
|
133
|
+
count = sum(1 for _ in graph.nodes_by_kind(kind))
|
|
134
|
+
if count > 0:
|
|
135
|
+
node_counts[kind.value] = count
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"root_count": graph.root_count(),
|
|
139
|
+
"node_counts": node_counts,
|
|
140
|
+
"total_nodes": graph.node_count(),
|
|
141
|
+
"has_orphans": graph.has_orphans(),
|
|
142
|
+
"has_broken_references": graph.has_broken_references(),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _refresh_graph(
|
|
147
|
+
repo_root: Path,
|
|
148
|
+
full: bool = False,
|
|
149
|
+
) -> tuple[dict[str, Any], TraceGraph]:
|
|
150
|
+
"""Rebuild the graph from spec files.
|
|
151
|
+
|
|
152
|
+
REQ-o00060-B: Forces graph rebuild.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
repo_root: Repository root path.
|
|
156
|
+
full: If True, clear all caches before rebuild.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Tuple of (result dict, new TraceGraph).
|
|
160
|
+
"""
|
|
161
|
+
# Build fresh graph
|
|
162
|
+
new_graph = build_graph(repo_root=repo_root)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"success": True,
|
|
166
|
+
"message": "Graph refreshed successfully",
|
|
167
|
+
"node_count": new_graph.node_count(),
|
|
168
|
+
}, new_graph
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _search(
|
|
172
|
+
graph: TraceGraph,
|
|
173
|
+
query: str,
|
|
174
|
+
field: str = "all",
|
|
175
|
+
regex: bool = False,
|
|
176
|
+
limit: int = 50,
|
|
177
|
+
) -> list[dict[str, Any]]:
|
|
178
|
+
"""Search requirements.
|
|
179
|
+
|
|
180
|
+
REQ-d00061-A: Iterates graph.nodes_by_kind(REQUIREMENT).
|
|
181
|
+
REQ-d00061-B: Supports field parameter (id, title, body, keywords, all).
|
|
182
|
+
REQ-d00061-C: Supports regex=True for regex matching.
|
|
183
|
+
REQ-d00061-D: Returns serialized requirement summaries.
|
|
184
|
+
REQ-d00061-E: Limits results to prevent unbounded sizes.
|
|
185
|
+
"""
|
|
186
|
+
results = []
|
|
187
|
+
|
|
188
|
+
# Compile pattern if regex mode
|
|
189
|
+
if regex:
|
|
190
|
+
try:
|
|
191
|
+
pattern = re.compile(query, re.IGNORECASE)
|
|
192
|
+
except re.error:
|
|
193
|
+
return []
|
|
194
|
+
else:
|
|
195
|
+
# Simple case-insensitive substring match
|
|
196
|
+
query_lower = query.lower()
|
|
197
|
+
|
|
198
|
+
for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
|
|
199
|
+
match = False
|
|
200
|
+
|
|
201
|
+
if field in ("id", "all"):
|
|
202
|
+
if regex:
|
|
203
|
+
if pattern.search(node.id):
|
|
204
|
+
match = True
|
|
205
|
+
else:
|
|
206
|
+
if query_lower in node.id.lower():
|
|
207
|
+
match = True
|
|
208
|
+
|
|
209
|
+
if not match and field in ("title", "all"):
|
|
210
|
+
title = node.get_label() or ""
|
|
211
|
+
if regex:
|
|
212
|
+
if pattern.search(title):
|
|
213
|
+
match = True
|
|
214
|
+
else:
|
|
215
|
+
if query_lower in title.lower():
|
|
216
|
+
match = True
|
|
217
|
+
|
|
218
|
+
if not match and field in ("keywords", "all"):
|
|
219
|
+
# Search in keywords field
|
|
220
|
+
keywords = node.get_field("keywords", [])
|
|
221
|
+
for keyword in keywords:
|
|
222
|
+
if regex:
|
|
223
|
+
if pattern.search(keyword):
|
|
224
|
+
match = True
|
|
225
|
+
break
|
|
226
|
+
else:
|
|
227
|
+
if query_lower in keyword.lower():
|
|
228
|
+
match = True
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
if match:
|
|
232
|
+
results.append(_serialize_requirement_summary(node))
|
|
233
|
+
if len(results) >= limit:
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
return results
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _get_requirement(graph: TraceGraph, req_id: str) -> dict[str, Any]:
|
|
240
|
+
"""Get single requirement details.
|
|
241
|
+
|
|
242
|
+
REQ-d00062-A: Uses graph.find_by_id() for O(1) lookup.
|
|
243
|
+
REQ-d00062-B: Returns node fields.
|
|
244
|
+
REQ-d00062-C: Returns assertions from iter_children().
|
|
245
|
+
REQ-d00062-D: Returns relationships from iter_outgoing_edges().
|
|
246
|
+
REQ-d00062-F: Returns error for non-existent requirements.
|
|
247
|
+
"""
|
|
248
|
+
node = graph.find_by_id(req_id)
|
|
249
|
+
|
|
250
|
+
if node is None:
|
|
251
|
+
return {"error": f"Requirement '{req_id}' not found"}
|
|
252
|
+
|
|
253
|
+
if node.kind != NodeKind.REQUIREMENT:
|
|
254
|
+
return {"error": f"Node '{req_id}' is not a requirement"}
|
|
255
|
+
|
|
256
|
+
return _serialize_requirement_full(node)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _get_hierarchy(graph: TraceGraph, req_id: str) -> dict[str, Any]:
|
|
260
|
+
"""Get requirement hierarchy.
|
|
261
|
+
|
|
262
|
+
REQ-d00063-A: Returns ancestors by walking iter_parents() recursively.
|
|
263
|
+
REQ-d00063-B: Returns children from iter_children().
|
|
264
|
+
REQ-d00063-D: Returns node summaries (id, title, level).
|
|
265
|
+
REQ-d00063-E: Handles DAG with multiple parents.
|
|
266
|
+
"""
|
|
267
|
+
node = graph.find_by_id(req_id)
|
|
268
|
+
|
|
269
|
+
if node is None:
|
|
270
|
+
return {"error": f"Requirement '{req_id}' not found"}
|
|
271
|
+
|
|
272
|
+
# Collect ancestors recursively (handles DAG)
|
|
273
|
+
ancestors = []
|
|
274
|
+
visited = set()
|
|
275
|
+
|
|
276
|
+
def walk_ancestors(n):
|
|
277
|
+
for parent in n.iter_parents():
|
|
278
|
+
if parent.id not in visited and parent.kind == NodeKind.REQUIREMENT:
|
|
279
|
+
visited.add(parent.id)
|
|
280
|
+
ancestors.append(_serialize_requirement_summary(parent))
|
|
281
|
+
walk_ancestors(parent)
|
|
282
|
+
|
|
283
|
+
walk_ancestors(node)
|
|
284
|
+
|
|
285
|
+
# Collect children (only requirements, not assertions)
|
|
286
|
+
children = []
|
|
287
|
+
for child in node.iter_children():
|
|
288
|
+
if child.kind == NodeKind.REQUIREMENT:
|
|
289
|
+
children.append(_serialize_requirement_summary(child))
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
"id": req_id,
|
|
293
|
+
"ancestors": ancestors,
|
|
294
|
+
"children": children,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
299
|
+
# Workspace Context Tools (REQ-o00061)
|
|
300
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _get_workspace_info(working_dir: Path) -> dict[str, Any]:
|
|
304
|
+
"""Get workspace information.
|
|
305
|
+
|
|
306
|
+
REQ-o00061-A: Returns repository path, project name, and configuration summary.
|
|
307
|
+
REQ-o00061-D: Reads configuration from unified config system.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
working_dir: The repository root directory.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Workspace information dict.
|
|
314
|
+
"""
|
|
315
|
+
config = get_config(start_path=working_dir, quiet=True)
|
|
316
|
+
|
|
317
|
+
# Get project name from config, fallback to directory name
|
|
318
|
+
project_name = config.get("project", {}).get("name")
|
|
319
|
+
if not project_name:
|
|
320
|
+
project_name = working_dir.name
|
|
321
|
+
|
|
322
|
+
# Build configuration summary
|
|
323
|
+
config_summary = {
|
|
324
|
+
"prefix": config.get("patterns", {}).get("prefix", "REQ"),
|
|
325
|
+
"spec_directories": config.get("spec", {}).get("directories", ["spec"]),
|
|
326
|
+
"testing_enabled": config.get("testing", {}).get("enabled", False),
|
|
327
|
+
"project_type": config.get("project", {}).get("type"),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
# Check if config file exists
|
|
331
|
+
config_file = find_config_file(working_dir)
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
"repo_path": str(working_dir),
|
|
335
|
+
"project_name": project_name,
|
|
336
|
+
"config_file": str(config_file) if config_file else None,
|
|
337
|
+
"config_summary": config_summary,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _get_project_summary(graph: TraceGraph, working_dir: Path) -> dict[str, Any]:
|
|
342
|
+
"""Get project summary statistics.
|
|
343
|
+
|
|
344
|
+
REQ-o00061-B: Returns requirement counts by level, coverage statistics, and change metrics.
|
|
345
|
+
REQ-o00061-C: Uses graph aggregate functions from annotators module.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
graph: The TraceGraph to analyze.
|
|
349
|
+
working_dir: The repository root directory.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Project summary dict.
|
|
353
|
+
"""
|
|
354
|
+
# Use aggregate functions from annotators (REQ-o00061-C)
|
|
355
|
+
level_counts = count_by_level(graph)
|
|
356
|
+
coverage_stats = count_by_coverage(graph)
|
|
357
|
+
change_metrics = count_by_git_status(graph)
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
"requirements_by_level": level_counts,
|
|
361
|
+
"coverage": coverage_stats,
|
|
362
|
+
"changes": change_metrics,
|
|
363
|
+
"total_nodes": graph.node_count(),
|
|
364
|
+
"orphan_count": graph.orphan_count(),
|
|
365
|
+
"broken_reference_count": len(graph.broken_references()),
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
370
|
+
# Mutation Tool Functions (REQ-o00062)
|
|
371
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _mutate_rename_node(graph: TraceGraph, old_id: str, new_id: str) -> dict[str, Any]:
|
|
375
|
+
"""Rename a node.
|
|
376
|
+
|
|
377
|
+
REQ-d00065-A: Delegates to graph.rename_node().
|
|
378
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
379
|
+
"""
|
|
380
|
+
try:
|
|
381
|
+
entry = graph.rename_node(old_id, new_id)
|
|
382
|
+
return {
|
|
383
|
+
"success": True,
|
|
384
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
385
|
+
"message": f"Renamed {old_id} to {new_id}",
|
|
386
|
+
}
|
|
387
|
+
except (ValueError, KeyError) as e:
|
|
388
|
+
return {"success": False, "error": str(e)}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _mutate_update_title(graph: TraceGraph, node_id: str, new_title: str) -> dict[str, Any]:
|
|
392
|
+
"""Update requirement title.
|
|
393
|
+
|
|
394
|
+
REQ-d00065-D: Only parameter validation and delegation.
|
|
395
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
396
|
+
"""
|
|
397
|
+
try:
|
|
398
|
+
entry = graph.update_title(node_id, new_title)
|
|
399
|
+
return {
|
|
400
|
+
"success": True,
|
|
401
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
402
|
+
"message": f"Updated title of {node_id}",
|
|
403
|
+
}
|
|
404
|
+
except (ValueError, KeyError) as e:
|
|
405
|
+
return {"success": False, "error": str(e)}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _mutate_change_status(graph: TraceGraph, node_id: str, new_status: str) -> dict[str, Any]:
|
|
409
|
+
"""Change requirement status.
|
|
410
|
+
|
|
411
|
+
REQ-d00065-D: Only parameter validation and delegation.
|
|
412
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
413
|
+
"""
|
|
414
|
+
try:
|
|
415
|
+
entry = graph.change_status(node_id, new_status)
|
|
416
|
+
return {
|
|
417
|
+
"success": True,
|
|
418
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
419
|
+
"message": f"Changed status of {node_id} to {new_status}",
|
|
420
|
+
}
|
|
421
|
+
except (ValueError, KeyError) as e:
|
|
422
|
+
return {"success": False, "error": str(e)}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _mutate_add_requirement(
|
|
426
|
+
graph: TraceGraph,
|
|
427
|
+
req_id: str,
|
|
428
|
+
title: str,
|
|
429
|
+
level: str,
|
|
430
|
+
status: str = "Draft",
|
|
431
|
+
parent_id: str | None = None,
|
|
432
|
+
edge_kind: str | None = None,
|
|
433
|
+
) -> dict[str, Any]:
|
|
434
|
+
"""Add a new requirement.
|
|
435
|
+
|
|
436
|
+
REQ-d00065-B: Delegates to graph.add_requirement().
|
|
437
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
438
|
+
"""
|
|
439
|
+
try:
|
|
440
|
+
# Convert edge_kind string to EdgeKind enum if provided
|
|
441
|
+
edge_kind_enum = None
|
|
442
|
+
if edge_kind:
|
|
443
|
+
edge_kind_enum = EdgeKind[edge_kind.upper()]
|
|
444
|
+
|
|
445
|
+
entry = graph.add_requirement(
|
|
446
|
+
req_id=req_id,
|
|
447
|
+
title=title,
|
|
448
|
+
level=level,
|
|
449
|
+
status=status,
|
|
450
|
+
parent_id=parent_id,
|
|
451
|
+
edge_kind=edge_kind_enum,
|
|
452
|
+
)
|
|
453
|
+
return {
|
|
454
|
+
"success": True,
|
|
455
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
456
|
+
"message": f"Added requirement {req_id}",
|
|
457
|
+
}
|
|
458
|
+
except (ValueError, KeyError) as e:
|
|
459
|
+
return {"success": False, "error": str(e)}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _mutate_delete_requirement(
|
|
463
|
+
graph: TraceGraph, node_id: str, confirm: bool = False
|
|
464
|
+
) -> dict[str, Any]:
|
|
465
|
+
"""Delete a requirement.
|
|
466
|
+
|
|
467
|
+
REQ-d00065-C: Calls graph.delete_requirement() only if confirm=True.
|
|
468
|
+
REQ-o00062-F: Requires confirm=True for destructive operations.
|
|
469
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
470
|
+
"""
|
|
471
|
+
if not confirm:
|
|
472
|
+
return {
|
|
473
|
+
"success": False,
|
|
474
|
+
"error": "Destructive operation requires confirm=True",
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
entry = graph.delete_requirement(node_id)
|
|
479
|
+
return {
|
|
480
|
+
"success": True,
|
|
481
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
482
|
+
"message": f"Deleted requirement {node_id}",
|
|
483
|
+
}
|
|
484
|
+
except (ValueError, KeyError) as e:
|
|
485
|
+
return {"success": False, "error": str(e)}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
489
|
+
# Assertion Mutation Functions (REQ-o00062-B)
|
|
490
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _mutate_add_assertion(graph: TraceGraph, req_id: str, label: str, text: str) -> dict[str, Any]:
|
|
494
|
+
"""Add assertion to requirement.
|
|
495
|
+
|
|
496
|
+
REQ-d00065-D: Only parameter validation and delegation.
|
|
497
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
498
|
+
"""
|
|
499
|
+
try:
|
|
500
|
+
entry = graph.add_assertion(req_id, label, text)
|
|
501
|
+
return {
|
|
502
|
+
"success": True,
|
|
503
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
504
|
+
"message": f"Added assertion {req_id}-{label}",
|
|
505
|
+
}
|
|
506
|
+
except (ValueError, KeyError) as e:
|
|
507
|
+
return {"success": False, "error": str(e)}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _mutate_update_assertion(graph: TraceGraph, assertion_id: str, new_text: str) -> dict[str, Any]:
|
|
511
|
+
"""Update assertion text.
|
|
512
|
+
|
|
513
|
+
REQ-d00065-D: Only parameter validation and delegation.
|
|
514
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
515
|
+
"""
|
|
516
|
+
try:
|
|
517
|
+
entry = graph.update_assertion(assertion_id, new_text)
|
|
518
|
+
return {
|
|
519
|
+
"success": True,
|
|
520
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
521
|
+
"message": f"Updated assertion {assertion_id}",
|
|
522
|
+
}
|
|
523
|
+
except (ValueError, KeyError) as e:
|
|
524
|
+
return {"success": False, "error": str(e)}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _mutate_delete_assertion(
|
|
528
|
+
graph: TraceGraph,
|
|
529
|
+
assertion_id: str,
|
|
530
|
+
compact: bool = True,
|
|
531
|
+
confirm: bool = False,
|
|
532
|
+
) -> dict[str, Any]:
|
|
533
|
+
"""Delete assertion.
|
|
534
|
+
|
|
535
|
+
REQ-o00062-F: Requires confirm=True for destructive operations.
|
|
536
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
537
|
+
"""
|
|
538
|
+
if not confirm:
|
|
539
|
+
return {
|
|
540
|
+
"success": False,
|
|
541
|
+
"error": "Destructive operation requires confirm=True",
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
entry = graph.delete_assertion(assertion_id, compact=compact)
|
|
546
|
+
return {
|
|
547
|
+
"success": True,
|
|
548
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
549
|
+
"message": f"Deleted assertion {assertion_id}",
|
|
550
|
+
}
|
|
551
|
+
except (ValueError, KeyError) as e:
|
|
552
|
+
return {"success": False, "error": str(e)}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _mutate_rename_assertion(graph: TraceGraph, old_id: str, new_label: str) -> dict[str, Any]:
|
|
556
|
+
"""Rename assertion label.
|
|
557
|
+
|
|
558
|
+
REQ-d00065-D: Only parameter validation and delegation.
|
|
559
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
560
|
+
"""
|
|
561
|
+
try:
|
|
562
|
+
entry = graph.rename_assertion(old_id, new_label)
|
|
563
|
+
return {
|
|
564
|
+
"success": True,
|
|
565
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
566
|
+
"message": f"Renamed assertion {old_id} to new label {new_label}",
|
|
567
|
+
}
|
|
568
|
+
except (ValueError, KeyError) as e:
|
|
569
|
+
return {"success": False, "error": str(e)}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
573
|
+
# Edge Mutation Functions (REQ-o00062-C)
|
|
574
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _mutate_add_edge(
|
|
578
|
+
graph: TraceGraph,
|
|
579
|
+
source_id: str,
|
|
580
|
+
target_id: str,
|
|
581
|
+
edge_kind: str,
|
|
582
|
+
assertion_targets: list[str] | None = None,
|
|
583
|
+
) -> dict[str, Any]:
|
|
584
|
+
"""Add an edge between nodes.
|
|
585
|
+
|
|
586
|
+
REQ-d00065-D: Only parameter validation and delegation.
|
|
587
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
588
|
+
"""
|
|
589
|
+
try:
|
|
590
|
+
edge_kind_enum = EdgeKind[edge_kind.upper()]
|
|
591
|
+
entry = graph.add_edge(
|
|
592
|
+
source_id=source_id,
|
|
593
|
+
target_id=target_id,
|
|
594
|
+
edge_kind=edge_kind_enum,
|
|
595
|
+
assertion_targets=assertion_targets,
|
|
596
|
+
)
|
|
597
|
+
return {
|
|
598
|
+
"success": True,
|
|
599
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
600
|
+
"message": f"Added edge {source_id} --[{edge_kind}]--> {target_id}",
|
|
601
|
+
}
|
|
602
|
+
except (ValueError, KeyError) as e:
|
|
603
|
+
return {"success": False, "error": str(e)}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _mutate_change_edge_kind(
|
|
607
|
+
graph: TraceGraph,
|
|
608
|
+
source_id: str,
|
|
609
|
+
target_id: str,
|
|
610
|
+
new_kind: str,
|
|
611
|
+
) -> dict[str, Any]:
|
|
612
|
+
"""Change edge type.
|
|
613
|
+
|
|
614
|
+
REQ-d00065-D: Only parameter validation and delegation.
|
|
615
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
616
|
+
"""
|
|
617
|
+
try:
|
|
618
|
+
new_kind_enum = EdgeKind[new_kind.upper()]
|
|
619
|
+
entry = graph.change_edge_kind(source_id, target_id, new_kind_enum)
|
|
620
|
+
return {
|
|
621
|
+
"success": True,
|
|
622
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
623
|
+
"message": f"Changed edge {source_id} -> {target_id} to {new_kind}",
|
|
624
|
+
}
|
|
625
|
+
except (ValueError, KeyError) as e:
|
|
626
|
+
return {"success": False, "error": str(e)}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _mutate_delete_edge(
|
|
630
|
+
graph: TraceGraph,
|
|
631
|
+
source_id: str,
|
|
632
|
+
target_id: str,
|
|
633
|
+
confirm: bool = False,
|
|
634
|
+
) -> dict[str, Any]:
|
|
635
|
+
"""Delete an edge.
|
|
636
|
+
|
|
637
|
+
REQ-o00062-F: Requires confirm=True for destructive operations.
|
|
638
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
639
|
+
"""
|
|
640
|
+
if not confirm:
|
|
641
|
+
return {
|
|
642
|
+
"success": False,
|
|
643
|
+
"error": "Destructive operation requires confirm=True",
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
entry = graph.delete_edge(source_id, target_id)
|
|
648
|
+
return {
|
|
649
|
+
"success": True,
|
|
650
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
651
|
+
"message": f"Deleted edge {source_id} -> {target_id}",
|
|
652
|
+
}
|
|
653
|
+
except (ValueError, KeyError) as e:
|
|
654
|
+
return {"success": False, "error": str(e)}
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _mutate_fix_broken_reference(
|
|
658
|
+
graph: TraceGraph,
|
|
659
|
+
source_id: str,
|
|
660
|
+
old_target_id: str,
|
|
661
|
+
new_target_id: str,
|
|
662
|
+
) -> dict[str, Any]:
|
|
663
|
+
"""Fix a broken reference by redirecting to a valid target.
|
|
664
|
+
|
|
665
|
+
REQ-d00065-D: Only parameter validation and delegation.
|
|
666
|
+
REQ-o00062-E: Returns MutationEntry for audit.
|
|
667
|
+
"""
|
|
668
|
+
try:
|
|
669
|
+
entry = graph.fix_broken_reference(source_id, old_target_id, new_target_id)
|
|
670
|
+
return {
|
|
671
|
+
"success": True,
|
|
672
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
673
|
+
"message": f"Fixed reference {source_id}: {old_target_id} -> {new_target_id}",
|
|
674
|
+
}
|
|
675
|
+
except (ValueError, KeyError) as e:
|
|
676
|
+
return {"success": False, "error": str(e)}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
680
|
+
# Undo Operations (REQ-o00062-G)
|
|
681
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _undo_last_mutation(graph: TraceGraph) -> dict[str, Any]:
|
|
685
|
+
"""Undo the most recent mutation.
|
|
686
|
+
|
|
687
|
+
REQ-o00062-G: Reverses mutations using graph.undo_last().
|
|
688
|
+
"""
|
|
689
|
+
entry = graph.undo_last()
|
|
690
|
+
if entry is None:
|
|
691
|
+
return {"success": False, "error": "No mutations to undo"}
|
|
692
|
+
|
|
693
|
+
return {
|
|
694
|
+
"success": True,
|
|
695
|
+
"mutation": _serialize_mutation_entry(entry),
|
|
696
|
+
"message": f"Undid {entry.operation} on {entry.target_id}",
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _undo_to_mutation(graph: TraceGraph, mutation_id: str) -> dict[str, Any]:
|
|
701
|
+
"""Undo all mutations back to a specific point.
|
|
702
|
+
|
|
703
|
+
REQ-o00062-G: Reverses mutations using graph.undo_to().
|
|
704
|
+
"""
|
|
705
|
+
try:
|
|
706
|
+
entries = graph.undo_to(mutation_id)
|
|
707
|
+
return {
|
|
708
|
+
"success": True,
|
|
709
|
+
"mutations_undone": len(entries),
|
|
710
|
+
"mutations": [_serialize_mutation_entry(e) for e in entries],
|
|
711
|
+
"message": f"Undid {len(entries)} mutations",
|
|
712
|
+
}
|
|
713
|
+
except (ValueError, KeyError) as e:
|
|
714
|
+
return {"success": False, "error": str(e)}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _get_mutation_log(graph: TraceGraph, limit: int = 50) -> dict[str, Any]:
|
|
718
|
+
"""Get mutation history.
|
|
719
|
+
|
|
720
|
+
Returns the most recent mutations from the log.
|
|
721
|
+
"""
|
|
722
|
+
mutations = []
|
|
723
|
+
for entry in graph.mutation_log.iter_entries():
|
|
724
|
+
mutations.append(_serialize_mutation_entry(entry))
|
|
725
|
+
if len(mutations) >= limit:
|
|
726
|
+
break
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
"mutations": mutations,
|
|
730
|
+
"count": len(mutations),
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
735
|
+
# Inspection Functions
|
|
736
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _get_orphaned_nodes(graph: TraceGraph) -> dict[str, Any]:
|
|
740
|
+
"""Get all orphaned nodes (nodes with no parents).
|
|
741
|
+
|
|
742
|
+
Returns nodes that have been orphaned due to edge deletions.
|
|
743
|
+
"""
|
|
744
|
+
orphans = []
|
|
745
|
+
for node in graph.orphaned_nodes():
|
|
746
|
+
if node.kind == NodeKind.REQUIREMENT:
|
|
747
|
+
orphans.append(_serialize_requirement_summary(node))
|
|
748
|
+
|
|
749
|
+
return {
|
|
750
|
+
"orphans": orphans,
|
|
751
|
+
"count": len(orphans),
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _get_broken_references(graph: TraceGraph) -> dict[str, Any]:
|
|
756
|
+
"""Get all broken references.
|
|
757
|
+
|
|
758
|
+
Returns edges that point to non-existent nodes.
|
|
759
|
+
"""
|
|
760
|
+
refs = [_serialize_broken_reference(ref) for ref in graph.broken_references()]
|
|
761
|
+
|
|
762
|
+
return {
|
|
763
|
+
"broken_references": refs,
|
|
764
|
+
"count": len(refs),
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
769
|
+
# Keyword Search Tools (Phase 4)
|
|
770
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _find_by_keywords(
|
|
774
|
+
graph: TraceGraph,
|
|
775
|
+
keywords: list[str],
|
|
776
|
+
match_all: bool = True,
|
|
777
|
+
) -> dict[str, Any]:
|
|
778
|
+
"""Find requirements containing specified keywords.
|
|
779
|
+
|
|
780
|
+
Args:
|
|
781
|
+
graph: The TraceGraph to search.
|
|
782
|
+
keywords: List of keywords to search for.
|
|
783
|
+
match_all: If True, node must contain ALL keywords (AND).
|
|
784
|
+
If False, node must contain ANY keyword (OR).
|
|
785
|
+
|
|
786
|
+
Returns:
|
|
787
|
+
Dict with 'success', 'results', and 'count'.
|
|
788
|
+
"""
|
|
789
|
+
from elspais.graph.annotators import find_by_keywords
|
|
790
|
+
|
|
791
|
+
nodes = find_by_keywords(graph, keywords, match_all)
|
|
792
|
+
results = [_serialize_requirement_summary(node) for node in nodes]
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
"success": True,
|
|
796
|
+
"results": results,
|
|
797
|
+
"count": len(results),
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _get_all_keywords(graph: TraceGraph) -> dict[str, Any]:
|
|
802
|
+
"""Get all unique keywords from the graph.
|
|
803
|
+
|
|
804
|
+
Args:
|
|
805
|
+
graph: The TraceGraph to scan.
|
|
806
|
+
|
|
807
|
+
Returns:
|
|
808
|
+
Dict with 'success', 'keywords', and 'count'.
|
|
809
|
+
"""
|
|
810
|
+
from elspais.graph.annotators import collect_all_keywords
|
|
811
|
+
|
|
812
|
+
keywords = collect_all_keywords(graph)
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
"success": True,
|
|
816
|
+
"keywords": keywords,
|
|
817
|
+
"count": len(keywords),
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
822
|
+
# Test Coverage Tools (REQ-o00064)
|
|
823
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _get_test_coverage(graph: TraceGraph, req_id: str) -> dict[str, Any]:
|
|
827
|
+
"""Get test coverage information for a requirement.
|
|
828
|
+
|
|
829
|
+
REQ-d00066-A: SHALL accept req_id parameter identifying the target requirement.
|
|
830
|
+
REQ-d00066-B: SHALL return TEST nodes by finding edges targeting the requirement.
|
|
831
|
+
REQ-d00066-C: SHALL return TEST_RESULT nodes linked to each TEST node.
|
|
832
|
+
REQ-d00066-D: SHALL identify covered assertions via edge assertion_targets.
|
|
833
|
+
REQ-d00066-E: SHALL return uncovered assertions as those with no incoming TEST edges.
|
|
834
|
+
REQ-d00066-F: SHALL return coverage summary with percentage.
|
|
835
|
+
REQ-d00066-G: SHALL use iterator-only API, not traverse full graph.
|
|
836
|
+
|
|
837
|
+
Args:
|
|
838
|
+
graph: The TraceGraph to query.
|
|
839
|
+
req_id: The requirement ID to get coverage for.
|
|
840
|
+
|
|
841
|
+
Returns:
|
|
842
|
+
Dict with success, test_nodes, result_nodes, covered/uncovered assertions,
|
|
843
|
+
and coverage statistics.
|
|
844
|
+
"""
|
|
845
|
+
# REQ-d00066-A: Get requirement by ID
|
|
846
|
+
node = graph.find_by_id(req_id)
|
|
847
|
+
if node is None:
|
|
848
|
+
return {"success": False, "error": f"Requirement {req_id} not found"}
|
|
849
|
+
|
|
850
|
+
if node.kind != NodeKind.REQUIREMENT:
|
|
851
|
+
return {"success": False, "error": f"{req_id} is not a requirement"}
|
|
852
|
+
|
|
853
|
+
# Collect assertions from children
|
|
854
|
+
assertions: list[tuple[str, str]] = [] # [(assertion_id, label), ...]
|
|
855
|
+
|
|
856
|
+
for child in node.iter_children():
|
|
857
|
+
if child.kind == NodeKind.ASSERTION:
|
|
858
|
+
label = child.get_field("label", "")
|
|
859
|
+
assertions.append((child.id, label))
|
|
860
|
+
|
|
861
|
+
assertion_ids = [a[0] for a in assertions]
|
|
862
|
+
|
|
863
|
+
# Track TEST nodes and covered assertions
|
|
864
|
+
test_nodes: list[dict[str, Any]] = []
|
|
865
|
+
result_nodes: list[dict[str, Any]] = []
|
|
866
|
+
covered_assertion_ids: set[str] = set()
|
|
867
|
+
seen_test_ids: set[str] = set() # Deduplicate tests
|
|
868
|
+
|
|
869
|
+
# REQ-d00066-B: Find TEST nodes via two patterns:
|
|
870
|
+
# 1. Edges from requirement with assertion_targets (real graph pattern)
|
|
871
|
+
# 2. Edges from assertions to TEST nodes (test fixture pattern)
|
|
872
|
+
|
|
873
|
+
# Pattern 1: Edges from requirement (e.g., annotate_coverage pattern)
|
|
874
|
+
for edge in node.iter_outgoing_edges():
|
|
875
|
+
target = edge.target
|
|
876
|
+
if target.kind != NodeKind.TEST:
|
|
877
|
+
continue
|
|
878
|
+
|
|
879
|
+
if target.id not in seen_test_ids:
|
|
880
|
+
seen_test_ids.add(target.id)
|
|
881
|
+
test_nodes.append(
|
|
882
|
+
{
|
|
883
|
+
"id": target.id,
|
|
884
|
+
"label": target.get_label(),
|
|
885
|
+
"file": target.get_field("file", ""),
|
|
886
|
+
"name": target.get_field("name", ""),
|
|
887
|
+
}
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# REQ-d00066-C: Get TEST_RESULT children
|
|
891
|
+
for child in target.iter_children():
|
|
892
|
+
if child.kind == NodeKind.TEST_RESULT:
|
|
893
|
+
result_nodes.append(
|
|
894
|
+
{
|
|
895
|
+
"id": child.id,
|
|
896
|
+
"status": child.get_field("status", "unknown"),
|
|
897
|
+
"duration": child.get_field("duration", 0.0),
|
|
898
|
+
"test_id": target.id,
|
|
899
|
+
}
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
# REQ-d00066-D: Track which assertions this test covers
|
|
903
|
+
if edge.assertion_targets:
|
|
904
|
+
for label in edge.assertion_targets:
|
|
905
|
+
# Find assertion ID by label
|
|
906
|
+
for aid, alabel in assertions:
|
|
907
|
+
if alabel == label:
|
|
908
|
+
covered_assertion_ids.add(aid)
|
|
909
|
+
break
|
|
910
|
+
|
|
911
|
+
# Pattern 2: Edges from assertions (test fixture pattern)
|
|
912
|
+
for assertion_id, _label in assertions:
|
|
913
|
+
assertion_node = graph.find_by_id(assertion_id)
|
|
914
|
+
if assertion_node is None:
|
|
915
|
+
continue
|
|
916
|
+
|
|
917
|
+
for edge in assertion_node.iter_outgoing_edges():
|
|
918
|
+
target = edge.target
|
|
919
|
+
if target.kind != NodeKind.TEST:
|
|
920
|
+
continue
|
|
921
|
+
|
|
922
|
+
# This assertion is covered by this test
|
|
923
|
+
covered_assertion_ids.add(assertion_id)
|
|
924
|
+
|
|
925
|
+
if target.id not in seen_test_ids:
|
|
926
|
+
seen_test_ids.add(target.id)
|
|
927
|
+
test_nodes.append(
|
|
928
|
+
{
|
|
929
|
+
"id": target.id,
|
|
930
|
+
"label": target.get_label(),
|
|
931
|
+
"file": target.get_field("file", ""),
|
|
932
|
+
"name": target.get_field("name", ""),
|
|
933
|
+
}
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
# REQ-d00066-C: Get TEST_RESULT children
|
|
937
|
+
for child in target.iter_children():
|
|
938
|
+
if child.kind == NodeKind.TEST_RESULT:
|
|
939
|
+
result_nodes.append(
|
|
940
|
+
{
|
|
941
|
+
"id": child.id,
|
|
942
|
+
"status": child.get_field("status", "unknown"),
|
|
943
|
+
"duration": child.get_field("duration", 0.0),
|
|
944
|
+
"test_id": target.id,
|
|
945
|
+
}
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
# REQ-d00066-E: Determine uncovered assertions
|
|
949
|
+
covered_assertions = sorted(covered_assertion_ids)
|
|
950
|
+
uncovered_assertions = sorted(set(assertion_ids) - covered_assertion_ids)
|
|
951
|
+
|
|
952
|
+
# REQ-d00066-F: Calculate coverage percentage
|
|
953
|
+
total = len(assertion_ids)
|
|
954
|
+
covered_count = len(covered_assertions)
|
|
955
|
+
coverage_pct = (covered_count / total * 100) if total > 0 else 0.0
|
|
956
|
+
|
|
957
|
+
return {
|
|
958
|
+
"success": True,
|
|
959
|
+
"req_id": req_id,
|
|
960
|
+
"test_nodes": test_nodes,
|
|
961
|
+
"result_nodes": result_nodes,
|
|
962
|
+
"covered_assertions": covered_assertions,
|
|
963
|
+
"uncovered_assertions": uncovered_assertions,
|
|
964
|
+
"total_assertions": total,
|
|
965
|
+
"covered_count": covered_count,
|
|
966
|
+
"coverage_pct": round(coverage_pct, 1),
|
|
967
|
+
}
|
|
968
|
+
|
|
6
969
|
|
|
7
|
-
|
|
8
|
-
|
|
970
|
+
def _get_uncovered_assertions(
|
|
971
|
+
graph: TraceGraph,
|
|
972
|
+
req_id: str | None = None,
|
|
973
|
+
limit: int = 100,
|
|
974
|
+
) -> dict[str, Any]:
|
|
975
|
+
"""Find assertions lacking test coverage.
|
|
9
976
|
|
|
10
|
-
|
|
11
|
-
|
|
977
|
+
REQ-d00067-A: SHALL accept optional req_id parameter; when None, scan all requirements.
|
|
978
|
+
REQ-d00067-B: SHALL iterate assertions using nodes_by_kind(ASSERTION).
|
|
979
|
+
REQ-d00067-C: SHALL check each assertion for incoming edges from TEST nodes.
|
|
980
|
+
REQ-d00067-D: SHALL return assertion details: id, text, label, parent requirement context.
|
|
981
|
+
REQ-d00067-E: SHALL return parent requirement id and title for context.
|
|
982
|
+
REQ-d00067-F: SHALL limit results to prevent unbounded response sizes.
|
|
12
983
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
984
|
+
Args:
|
|
985
|
+
graph: The TraceGraph to query.
|
|
986
|
+
req_id: Optional requirement ID to filter by. If None, scan all requirements.
|
|
987
|
+
limit: Maximum number of results to return.
|
|
988
|
+
|
|
989
|
+
Returns:
|
|
990
|
+
Dict with success and list of uncovered assertions with parent context.
|
|
991
|
+
"""
|
|
992
|
+
|
|
993
|
+
def _is_assertion_covered(assertion_node: Any) -> bool:
|
|
994
|
+
"""Check if an assertion has any TEST coverage."""
|
|
995
|
+
# Check outgoing edges from assertion (test fixture pattern)
|
|
996
|
+
for edge in assertion_node.iter_outgoing_edges():
|
|
997
|
+
if edge.target.kind == NodeKind.TEST:
|
|
998
|
+
return True
|
|
999
|
+
|
|
1000
|
+
# Check if parent requirement has edges to TEST with this assertion as target
|
|
1001
|
+
for parent in assertion_node.iter_parents():
|
|
1002
|
+
if parent.kind != NodeKind.REQUIREMENT:
|
|
1003
|
+
continue
|
|
1004
|
+
label = assertion_node.get_field("label", "")
|
|
1005
|
+
for edge in parent.iter_outgoing_edges():
|
|
1006
|
+
if edge.target.kind != NodeKind.TEST:
|
|
1007
|
+
continue
|
|
1008
|
+
if edge.assertion_targets and label in edge.assertion_targets:
|
|
1009
|
+
return True
|
|
1010
|
+
|
|
1011
|
+
return False
|
|
1012
|
+
|
|
1013
|
+
uncovered: list[dict[str, Any]] = []
|
|
1014
|
+
|
|
1015
|
+
if req_id is not None:
|
|
1016
|
+
# REQ-d00067-B: Filter to specific requirement's assertions
|
|
1017
|
+
node = graph.find_by_id(req_id)
|
|
1018
|
+
if node is None:
|
|
1019
|
+
return {"success": False, "error": f"Requirement {req_id} not found"}
|
|
1020
|
+
|
|
1021
|
+
if node.kind != NodeKind.REQUIREMENT:
|
|
1022
|
+
return {"success": False, "error": f"{req_id} is not a requirement"}
|
|
1023
|
+
|
|
1024
|
+
for child in node.iter_children():
|
|
1025
|
+
if child.kind != NodeKind.ASSERTION:
|
|
1026
|
+
continue
|
|
1027
|
+
if _is_assertion_covered(child):
|
|
1028
|
+
continue
|
|
1029
|
+
|
|
1030
|
+
# REQ-d00067-D, REQ-d00067-E: Include assertion and parent context
|
|
1031
|
+
uncovered.append(
|
|
1032
|
+
{
|
|
1033
|
+
"id": child.id,
|
|
1034
|
+
"label": child.get_field("label", ""),
|
|
1035
|
+
"text": child.get_label(),
|
|
1036
|
+
"parent_id": req_id,
|
|
1037
|
+
"parent_title": node.get_label(),
|
|
1038
|
+
}
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
if len(uncovered) >= limit:
|
|
1042
|
+
break
|
|
1043
|
+
else:
|
|
1044
|
+
# REQ-d00067-A: Scan all assertions
|
|
1045
|
+
# Group by parent requirement for sorted output
|
|
1046
|
+
req_assertions: dict[str, list[Any]] = {}
|
|
1047
|
+
|
|
1048
|
+
for node in graph.nodes_by_kind(NodeKind.ASSERTION):
|
|
1049
|
+
if _is_assertion_covered(node):
|
|
1050
|
+
continue
|
|
1051
|
+
|
|
1052
|
+
# Find parent requirement
|
|
1053
|
+
parent_req = None
|
|
1054
|
+
for parent in node.iter_parents():
|
|
1055
|
+
if parent.kind == NodeKind.REQUIREMENT:
|
|
1056
|
+
parent_req = parent
|
|
1057
|
+
break
|
|
1058
|
+
|
|
1059
|
+
if parent_req is None:
|
|
1060
|
+
continue
|
|
1061
|
+
|
|
1062
|
+
if parent_req.id not in req_assertions:
|
|
1063
|
+
req_assertions[parent_req.id] = []
|
|
1064
|
+
req_assertions[parent_req.id].append((node, parent_req))
|
|
1065
|
+
|
|
1066
|
+
# Sort by requirement ID and build output
|
|
1067
|
+
for req_id_key in sorted(req_assertions.keys()):
|
|
1068
|
+
for assertion_node, parent_req in req_assertions[req_id_key]:
|
|
1069
|
+
uncovered.append(
|
|
1070
|
+
{
|
|
1071
|
+
"id": assertion_node.id,
|
|
1072
|
+
"label": assertion_node.get_field("label", ""),
|
|
1073
|
+
"text": assertion_node.get_label(),
|
|
1074
|
+
"parent_id": parent_req.id,
|
|
1075
|
+
"parent_title": parent_req.get_label(),
|
|
1076
|
+
}
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
if len(uncovered) >= limit:
|
|
1080
|
+
break
|
|
1081
|
+
|
|
1082
|
+
if len(uncovered) >= limit:
|
|
1083
|
+
break
|
|
1084
|
+
|
|
1085
|
+
return {
|
|
1086
|
+
"success": True,
|
|
1087
|
+
"assertions": uncovered,
|
|
1088
|
+
"count": len(uncovered),
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _find_assertions_by_keywords(
|
|
1093
|
+
graph: TraceGraph,
|
|
1094
|
+
keywords: list[str],
|
|
1095
|
+
match_all: bool = True,
|
|
1096
|
+
) -> dict[str, Any]:
|
|
1097
|
+
"""Find assertions containing specified keywords.
|
|
1098
|
+
|
|
1099
|
+
REQ-d00068-A: SHALL accept keywords list parameter with search terms.
|
|
1100
|
+
REQ-d00068-B: SHALL accept match_all boolean; True requires all keywords.
|
|
1101
|
+
REQ-d00068-C: SHALL search assertion text (SHALL statement content).
|
|
1102
|
+
REQ-d00068-D: SHALL return assertion id, text, label, and parent context.
|
|
1103
|
+
REQ-d00068-E: SHALL perform case-insensitive matching by default.
|
|
1104
|
+
REQ-d00068-F: SHALL complement find_by_keywords() which searches requirements.
|
|
1105
|
+
|
|
1106
|
+
Args:
|
|
1107
|
+
graph: The TraceGraph to query.
|
|
1108
|
+
keywords: List of keywords to search for.
|
|
1109
|
+
match_all: If True, assertion must contain ALL keywords (AND).
|
|
1110
|
+
If False, assertion must contain ANY keyword (OR).
|
|
1111
|
+
|
|
1112
|
+
Returns:
|
|
1113
|
+
Dict with success and list of matching assertions with parent context.
|
|
1114
|
+
"""
|
|
1115
|
+
from elspais.graph.annotators import find_by_keywords
|
|
1116
|
+
|
|
1117
|
+
# REQ-d00068-C: Use graph API to search assertion nodes by keyword
|
|
1118
|
+
# REQ-d00068-E: find_by_keywords handles case normalization
|
|
1119
|
+
nodes = find_by_keywords(graph, keywords, match_all, kind=NodeKind.ASSERTION)
|
|
1120
|
+
|
|
1121
|
+
# REQ-d00068-D: Format results with parent context
|
|
1122
|
+
results: list[dict[str, Any]] = []
|
|
1123
|
+
for node in nodes:
|
|
1124
|
+
# Find parent requirement
|
|
1125
|
+
parent_req = None
|
|
1126
|
+
for parent in node.iter_parents():
|
|
1127
|
+
if parent.kind == NodeKind.REQUIREMENT:
|
|
1128
|
+
parent_req = parent
|
|
1129
|
+
break
|
|
1130
|
+
|
|
1131
|
+
results.append(
|
|
1132
|
+
{
|
|
1133
|
+
"id": node.id,
|
|
1134
|
+
"label": node.get_field("label", ""),
|
|
1135
|
+
"text": node.get_label() or "",
|
|
1136
|
+
"parent_id": parent_req.id if parent_req else None,
|
|
1137
|
+
"parent_title": parent_req.get_label() if parent_req else None,
|
|
1138
|
+
}
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
return {
|
|
1142
|
+
"success": True,
|
|
1143
|
+
"assertions": results,
|
|
1144
|
+
"count": len(results),
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1149
|
+
# File Mutation Tools (REQ-o00063)
|
|
1150
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _change_reference_type(
|
|
1154
|
+
repo_root: Path,
|
|
1155
|
+
req_id: str,
|
|
1156
|
+
target_id: str,
|
|
1157
|
+
new_type: str,
|
|
1158
|
+
save_branch: bool = False,
|
|
1159
|
+
) -> dict[str, Any]:
|
|
1160
|
+
"""Change a reference type in a spec file (Implements -> Refines or vice versa).
|
|
1161
|
+
|
|
1162
|
+
REQ-o00063-A: Modify Implements/Refines relationships in spec files.
|
|
1163
|
+
|
|
1164
|
+
Args:
|
|
1165
|
+
repo_root: Repository root path.
|
|
1166
|
+
req_id: ID of the requirement to modify.
|
|
1167
|
+
target_id: ID of the target requirement being referenced.
|
|
1168
|
+
new_type: New reference type ('IMPLEMENTS' or 'REFINES').
|
|
1169
|
+
save_branch: If True, create a safety branch before modifying.
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
Success status and optional safety_branch name.
|
|
1173
|
+
"""
|
|
1174
|
+
from elspais.utilities.git import create_safety_branch
|
|
1175
|
+
|
|
1176
|
+
# Normalize type
|
|
1177
|
+
new_type_lower = new_type.lower()
|
|
1178
|
+
if new_type_lower not in ("implements", "refines"):
|
|
1179
|
+
return {"success": False, "error": f"Invalid reference type: {new_type}"}
|
|
1180
|
+
|
|
1181
|
+
# Find the spec file containing req_id
|
|
1182
|
+
spec_dir = repo_root / "spec"
|
|
1183
|
+
if not spec_dir.exists():
|
|
1184
|
+
return {"success": False, "error": "spec/ directory not found"}
|
|
1185
|
+
|
|
1186
|
+
# Search for the requirement in spec files
|
|
1187
|
+
target_file = None
|
|
1188
|
+
for md_file in spec_dir.rglob("*.md"):
|
|
1189
|
+
content = md_file.read_text(encoding="utf-8")
|
|
1190
|
+
if f"## {req_id}:" in content or f"### {req_id}:" in content:
|
|
1191
|
+
target_file = md_file
|
|
1192
|
+
break
|
|
1193
|
+
|
|
1194
|
+
if target_file is None:
|
|
1195
|
+
return {"success": False, "error": f"Requirement {req_id} not found in spec files"}
|
|
1196
|
+
|
|
1197
|
+
content = target_file.read_text(encoding="utf-8")
|
|
1198
|
+
|
|
1199
|
+
# Create safety branch if requested
|
|
1200
|
+
safety_branch = None
|
|
1201
|
+
if save_branch:
|
|
1202
|
+
branch_result = create_safety_branch(repo_root, req_id)
|
|
1203
|
+
if not branch_result["success"]:
|
|
1204
|
+
error_msg = branch_result.get("error", "Failed to create safety branch")
|
|
1205
|
+
return {"success": False, "error": error_msg}
|
|
1206
|
+
safety_branch = branch_result["branch_name"]
|
|
1207
|
+
|
|
1208
|
+
# Find and replace the reference pattern
|
|
1209
|
+
# Match patterns like: **Implements**: REQ-p00001 or **Refines**: REQ-p00001
|
|
1210
|
+
old_patterns = [
|
|
1211
|
+
f"**Implements**: {target_id}",
|
|
1212
|
+
f"**Refines**: {target_id}",
|
|
1213
|
+
f"Implements: {target_id}",
|
|
1214
|
+
f"Refines: {target_id}",
|
|
1215
|
+
]
|
|
1216
|
+
|
|
1217
|
+
# Capitalize for display
|
|
1218
|
+
new_type_display = new_type_lower.capitalize()
|
|
1219
|
+
new_text = f"**{new_type_display}**: {target_id}"
|
|
1220
|
+
|
|
1221
|
+
modified = False
|
|
1222
|
+
for pattern in old_patterns:
|
|
1223
|
+
if pattern in content:
|
|
1224
|
+
content = content.replace(pattern, new_text)
|
|
1225
|
+
modified = True
|
|
1226
|
+
break
|
|
1227
|
+
|
|
1228
|
+
if not modified:
|
|
1229
|
+
return {"success": False, "error": f"Reference to {target_id} not found in {req_id}"}
|
|
1230
|
+
|
|
1231
|
+
# Write the modified content
|
|
1232
|
+
target_file.write_text(content, encoding="utf-8")
|
|
1233
|
+
|
|
1234
|
+
result: dict[str, Any] = {"success": True}
|
|
1235
|
+
if safety_branch:
|
|
1236
|
+
result["safety_branch"] = safety_branch
|
|
1237
|
+
|
|
1238
|
+
return result
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _move_requirement(
|
|
1242
|
+
repo_root: Path,
|
|
1243
|
+
req_id: str,
|
|
1244
|
+
target_file: str,
|
|
1245
|
+
save_branch: bool = False,
|
|
1246
|
+
) -> dict[str, Any]:
|
|
1247
|
+
"""Move a requirement from one spec file to another.
|
|
1248
|
+
|
|
1249
|
+
REQ-o00063-B: Relocate a requirement between spec files.
|
|
1250
|
+
|
|
1251
|
+
Args:
|
|
1252
|
+
repo_root: Repository root path.
|
|
1253
|
+
req_id: ID of the requirement to move.
|
|
1254
|
+
target_file: Relative path to the target file.
|
|
1255
|
+
save_branch: If True, create a safety branch before modifying.
|
|
1256
|
+
|
|
1257
|
+
Returns:
|
|
1258
|
+
Success status and optional safety_branch name.
|
|
1259
|
+
"""
|
|
1260
|
+
from elspais.utilities.git import create_safety_branch
|
|
1261
|
+
|
|
1262
|
+
spec_dir = repo_root / "spec"
|
|
1263
|
+
if not spec_dir.exists():
|
|
1264
|
+
return {"success": False, "error": "spec/ directory not found"}
|
|
1265
|
+
|
|
1266
|
+
target_path = repo_root / target_file
|
|
1267
|
+
if not target_path.exists():
|
|
1268
|
+
return {"success": False, "error": f"Target file {target_file} not found"}
|
|
1269
|
+
|
|
1270
|
+
# Find the source file containing req_id
|
|
1271
|
+
source_file = None
|
|
1272
|
+
for md_file in spec_dir.rglob("*.md"):
|
|
1273
|
+
content = md_file.read_text(encoding="utf-8")
|
|
1274
|
+
if f"## {req_id}:" in content or f"### {req_id}:" in content:
|
|
1275
|
+
source_file = md_file
|
|
1276
|
+
break
|
|
1277
|
+
|
|
1278
|
+
if source_file is None:
|
|
1279
|
+
return {"success": False, "error": f"Requirement {req_id} not found in spec files"}
|
|
1280
|
+
|
|
1281
|
+
if source_file == target_path:
|
|
1282
|
+
return {"success": False, "error": "Source and target files are the same"}
|
|
1283
|
+
|
|
1284
|
+
# Create safety branch if requested
|
|
1285
|
+
safety_branch = None
|
|
1286
|
+
if save_branch:
|
|
1287
|
+
branch_result = create_safety_branch(repo_root, req_id)
|
|
1288
|
+
if not branch_result["success"]:
|
|
1289
|
+
error_msg = branch_result.get("error", "Failed to create safety branch")
|
|
1290
|
+
return {"success": False, "error": error_msg}
|
|
1291
|
+
safety_branch = branch_result["branch_name"]
|
|
1292
|
+
|
|
1293
|
+
# Extract the requirement block from source
|
|
1294
|
+
source_content = source_file.read_text(encoding="utf-8")
|
|
1295
|
+
|
|
1296
|
+
# Find the requirement block (from header to *End* marker or next ## header)
|
|
1297
|
+
# Pattern: ## REQ-xxx: Title ... *End* *Title* | **Hash**: xxx
|
|
1298
|
+
header_pattern = re.compile(
|
|
1299
|
+
rf"(^##+ {re.escape(req_id)}:.*?(?:\*End\*.*?(?:\*\*Hash\*\*:.*?\n|$)|(?=^##+ REQ-)))",
|
|
1300
|
+
re.MULTILINE | re.DOTALL,
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
match = header_pattern.search(source_content)
|
|
1304
|
+
if not match:
|
|
1305
|
+
return {"success": False, "error": f"Could not parse requirement block for {req_id}"}
|
|
1306
|
+
|
|
1307
|
+
req_block = match.group(1)
|
|
1308
|
+
|
|
1309
|
+
# Remove from source
|
|
1310
|
+
new_source_content = source_content[: match.start()] + source_content[match.end() :]
|
|
1311
|
+
# Clean up multiple consecutive blank lines
|
|
1312
|
+
new_source_content = re.sub(r"\n{3,}", "\n\n", new_source_content)
|
|
17
1313
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1314
|
+
# Append to target
|
|
1315
|
+
target_content = target_path.read_text(encoding="utf-8")
|
|
1316
|
+
if not target_content.endswith("\n\n"):
|
|
1317
|
+
if not target_content.endswith("\n"):
|
|
1318
|
+
target_content += "\n"
|
|
1319
|
+
target_content += "\n"
|
|
1320
|
+
target_content += "---\n\n" + req_block
|
|
25
1321
|
|
|
1322
|
+
# Write both files
|
|
1323
|
+
source_file.write_text(new_source_content, encoding="utf-8")
|
|
1324
|
+
target_path.write_text(target_content, encoding="utf-8")
|
|
26
1325
|
|
|
27
|
-
|
|
1326
|
+
result: dict[str, Any] = {"success": True}
|
|
1327
|
+
if safety_branch:
|
|
1328
|
+
result["safety_branch"] = safety_branch
|
|
1329
|
+
|
|
1330
|
+
return result
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _restore_from_safety_branch(
|
|
1334
|
+
repo_root: Path,
|
|
1335
|
+
branch_name: str,
|
|
1336
|
+
) -> dict[str, Any]:
|
|
1337
|
+
"""Restore spec files from a safety branch.
|
|
1338
|
+
|
|
1339
|
+
REQ-o00063-E: Revert file changes from a safety branch.
|
|
1340
|
+
|
|
1341
|
+
Args:
|
|
1342
|
+
repo_root: Repository root path.
|
|
1343
|
+
branch_name: Name of the safety branch to restore from.
|
|
1344
|
+
|
|
1345
|
+
Returns:
|
|
1346
|
+
Success status.
|
|
28
1347
|
"""
|
|
29
|
-
|
|
1348
|
+
from elspais.utilities.git import restore_from_safety_branch
|
|
1349
|
+
|
|
1350
|
+
return restore_from_safety_branch(repo_root, branch_name)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def _list_safety_branches_impl(repo_root: Path) -> dict[str, Any]:
|
|
1354
|
+
"""List all safety branches.
|
|
30
1355
|
|
|
31
1356
|
Args:
|
|
32
|
-
|
|
33
|
-
Defaults to current working directory
|
|
1357
|
+
repo_root: Repository root path.
|
|
34
1358
|
|
|
35
1359
|
Returns:
|
|
36
|
-
|
|
1360
|
+
List of safety branch names.
|
|
1361
|
+
"""
|
|
1362
|
+
from elspais.utilities.git import list_safety_branches
|
|
1363
|
+
|
|
1364
|
+
branches = list_safety_branches(repo_root)
|
|
1365
|
+
return {"branches": branches, "count": len(branches)}
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1369
|
+
# MCP Server Instructions
|
|
1370
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1371
|
+
|
|
1372
|
+
MCP_SERVER_INSTRUCTIONS = """\
|
|
1373
|
+
elspais MCP Server - AI-Driven Requirements Management
|
|
1374
|
+
|
|
1375
|
+
This server provides tools to navigate, analyze, and mutate a requirements traceability graph.
|
|
1376
|
+
The graph is the single source of truth - all tools read directly from it.
|
|
1377
|
+
|
|
1378
|
+
## Quick Start
|
|
1379
|
+
|
|
1380
|
+
1. `get_workspace_info()` - Understand what project you're working with
|
|
1381
|
+
2. `get_project_summary()` - Get overview statistics and health metrics
|
|
1382
|
+
3. `search(query)` - Find requirements by keyword
|
|
1383
|
+
4. `get_requirement(req_id)` - Get full details including assertions
|
|
1384
|
+
5. `get_hierarchy(req_id)` - Navigate parent/child relationships
|
|
1385
|
+
|
|
1386
|
+
## Tools Overview
|
|
1387
|
+
|
|
1388
|
+
### Graph Status & Control
|
|
1389
|
+
- `get_graph_status()` - Node counts, orphan/broken reference flags
|
|
1390
|
+
- `refresh_graph(full=False)` - Rebuild after spec file changes
|
|
1391
|
+
|
|
1392
|
+
### Search & Navigation
|
|
1393
|
+
- `search(query, field="all", regex=False, limit=50)` - Find requirements
|
|
1394
|
+
- field: "id", "title", "body", or "all"
|
|
1395
|
+
- regex: treat query as regex pattern
|
|
1396
|
+
- `get_requirement(req_id)` - Full details with assertions and relationships
|
|
1397
|
+
- `get_hierarchy(req_id)` - Ancestors (to roots) and direct children
|
|
1398
|
+
|
|
1399
|
+
### Workspace Context
|
|
1400
|
+
- `get_workspace_info()` - Repo path, project name, configuration
|
|
1401
|
+
- `get_project_summary()` - Counts by level, coverage stats, change metrics
|
|
1402
|
+
|
|
1403
|
+
### Node Mutations (in-memory)
|
|
1404
|
+
- `mutate_rename_node(old_id, new_id)` - Rename requirement
|
|
1405
|
+
- `mutate_update_title(node_id, new_title)` - Change title
|
|
1406
|
+
- `mutate_change_status(node_id, new_status)` - Change status
|
|
1407
|
+
- `mutate_add_requirement(req_id, title, level, ...)` - Create requirement
|
|
1408
|
+
- `mutate_delete_requirement(node_id, confirm=True)` - Delete requirement (requires confirm)
|
|
1409
|
+
|
|
1410
|
+
### Assertion Mutations (in-memory)
|
|
1411
|
+
- `mutate_add_assertion(req_id, label, text)` - Add assertion
|
|
1412
|
+
- `mutate_update_assertion(assertion_id, new_text)` - Update text
|
|
1413
|
+
- `mutate_delete_assertion(assertion_id, confirm=True)` - Delete (requires confirm)
|
|
1414
|
+
- `mutate_rename_assertion(old_id, new_label)` - Rename label
|
|
1415
|
+
|
|
1416
|
+
### Edge Mutations (in-memory)
|
|
1417
|
+
- `mutate_add_edge(source_id, target_id, edge_kind)` - Add relationship
|
|
1418
|
+
- `mutate_change_edge_kind(source_id, target_id, new_kind)` - Change type
|
|
1419
|
+
- `mutate_delete_edge(source_id, target_id, confirm=True)` - Delete (requires confirm)
|
|
1420
|
+
- `mutate_fix_broken_reference(source_id, old_target, new_target)` - Fix broken ref
|
|
1421
|
+
|
|
1422
|
+
### Undo & Inspection
|
|
1423
|
+
- `undo_last_mutation()` - Undo most recent mutation
|
|
1424
|
+
- `undo_to_mutation(mutation_id)` - Undo back to specific point
|
|
1425
|
+
- `get_mutation_log(limit=50)` - View mutation history
|
|
1426
|
+
- `get_orphaned_nodes()` - List orphaned nodes
|
|
1427
|
+
- `get_broken_references()` - List broken references
|
|
1428
|
+
|
|
1429
|
+
### Test Coverage Analysis
|
|
1430
|
+
- `get_test_coverage(req_id)` - Get TEST nodes and coverage stats for a requirement
|
|
1431
|
+
- Returns test_nodes, result_nodes, covered/uncovered assertions
|
|
1432
|
+
- Includes coverage percentage calculation
|
|
1433
|
+
- `get_uncovered_assertions(req_id=None)` - Find assertions with no test coverage
|
|
1434
|
+
- When req_id is None, scans all requirements
|
|
1435
|
+
- Returns assertion details with parent requirement context
|
|
1436
|
+
- `find_assertions_by_keywords(keywords, match_all=True)` - Search assertion text
|
|
1437
|
+
- match_all=True requires ALL keywords, False requires ANY
|
|
1438
|
+
- Complements find_by_keywords() which searches requirement titles
|
|
1439
|
+
|
|
1440
|
+
## Requirement Levels
|
|
1441
|
+
|
|
1442
|
+
Requirements follow a three-tier hierarchy:
|
|
1443
|
+
- **PRD** (Product): High-level product requirements
|
|
1444
|
+
- **OPS** (Operations): Operational/process requirements
|
|
1445
|
+
- **DEV** (Development): Technical implementation requirements
|
|
1446
|
+
|
|
1447
|
+
Children implement parents: DEV -> OPS -> PRD
|
|
1448
|
+
|
|
1449
|
+
**Note:** The exact ID syntax (prefixes, patterns) and hierarchy rules are
|
|
1450
|
+
configurable per project via `.elspais.toml`. Use `get_workspace_info()` to
|
|
1451
|
+
see the current project's configuration including the ID prefix and pattern.
|
|
1452
|
+
|
|
1453
|
+
## Important: In-Memory vs File Mutations
|
|
1454
|
+
|
|
1455
|
+
Mutation tools modify the **in-memory graph only**. Changes are NOT persisted
|
|
1456
|
+
to spec files automatically. This allows you to:
|
|
1457
|
+
1. Draft changes and review them
|
|
1458
|
+
2. Use undo to revert mistakes
|
|
1459
|
+
3. Refresh the graph to discard all changes
|
|
1460
|
+
|
|
1461
|
+
To persist changes, use the file mutation tools:
|
|
1462
|
+
|
|
1463
|
+
### File Mutations (persistent)
|
|
1464
|
+
- `change_reference_type(req_id, target_id, new_type, save_branch)` - Change Implements/Refines
|
|
1465
|
+
- `move_requirement(req_id, target_file, save_branch)` - Move requirement to different file
|
|
1466
|
+
- `restore_from_safety_branch(branch_name)` - Revert file changes
|
|
1467
|
+
- `list_safety_branches()` - List available safety branches
|
|
1468
|
+
|
|
1469
|
+
Use `save_branch=True` to create a safety branch before modifications, allowing rollback.
|
|
1470
|
+
|
|
1471
|
+
## Common Patterns
|
|
1472
|
+
|
|
1473
|
+
**Understanding a requirement:**
|
|
1474
|
+
1. get_requirement("REQ-p00001") for details and assertions
|
|
1475
|
+
2. get_hierarchy("REQ-p00001") to see where it fits
|
|
1476
|
+
|
|
1477
|
+
**Finding related requirements:**
|
|
1478
|
+
1. search("authentication") to find by keyword
|
|
1479
|
+
2. get_hierarchy() on results to navigate relationships
|
|
1480
|
+
|
|
1481
|
+
**Checking project health:**
|
|
1482
|
+
1. get_graph_status() for orphans/broken refs
|
|
1483
|
+
2. get_project_summary() for coverage gaps
|
|
1484
|
+
|
|
1485
|
+
**Drafting requirement changes:**
|
|
1486
|
+
1. mutate_add_requirement() to create draft
|
|
1487
|
+
2. mutate_add_assertion() to add assertions
|
|
1488
|
+
3. get_mutation_log() to review changes
|
|
1489
|
+
4. undo_last_mutation() if needed
|
|
1490
|
+
|
|
1491
|
+
**After editing spec files:**
|
|
1492
|
+
1. refresh_graph() to rebuild
|
|
1493
|
+
2. get_graph_status() to verify health
|
|
1494
|
+
"""
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1498
|
+
# MCP Server Factory
|
|
1499
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1500
|
+
|
|
37
1501
|
|
|
38
|
-
|
|
39
|
-
|
|
1502
|
+
def create_server(
|
|
1503
|
+
graph: TraceGraph | None = None,
|
|
1504
|
+
working_dir: Path | None = None,
|
|
1505
|
+
) -> FastMCP:
|
|
1506
|
+
"""Create the MCP server with all tools registered.
|
|
1507
|
+
|
|
1508
|
+
Args:
|
|
1509
|
+
graph: Optional pre-built graph (for testing).
|
|
1510
|
+
working_dir: Working directory for graph building.
|
|
1511
|
+
|
|
1512
|
+
Returns:
|
|
1513
|
+
FastMCP server instance.
|
|
40
1514
|
"""
|
|
41
1515
|
if not MCP_AVAILABLE:
|
|
42
|
-
raise ImportError(
|
|
43
|
-
"MCP dependencies not installed. " "Install with: pip install elspais[mcp]"
|
|
44
|
-
)
|
|
1516
|
+
raise ImportError("MCP dependencies not installed. Install with: pip install elspais[mcp]")
|
|
45
1517
|
|
|
1518
|
+
# Initialize working directory
|
|
46
1519
|
if working_dir is None:
|
|
47
1520
|
working_dir = Path.cwd()
|
|
48
1521
|
|
|
49
|
-
#
|
|
50
|
-
|
|
1522
|
+
# Build initial graph if not provided
|
|
1523
|
+
if graph is None:
|
|
1524
|
+
graph = build_graph(repo_root=working_dir)
|
|
51
1525
|
|
|
52
|
-
# Create
|
|
53
|
-
mcp = FastMCP(
|
|
54
|
-
name="elspais",
|
|
55
|
-
)
|
|
1526
|
+
# Create server with instructions for AI agents (REQ-d00065)
|
|
1527
|
+
mcp = FastMCP("elspais", instructions=MCP_SERVER_INSTRUCTIONS)
|
|
56
1528
|
|
|
57
|
-
#
|
|
58
|
-
|
|
1529
|
+
# Store graph in closure for tools
|
|
1530
|
+
_state = {"graph": graph, "working_dir": working_dir}
|
|
59
1531
|
|
|
60
|
-
#
|
|
61
|
-
|
|
1532
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1533
|
+
# Register Tools
|
|
1534
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
62
1535
|
|
|
63
|
-
|
|
1536
|
+
@mcp.tool()
|
|
1537
|
+
def get_graph_status() -> dict[str, Any]:
|
|
1538
|
+
"""Get current graph status.
|
|
64
1539
|
|
|
1540
|
+
Returns node counts by kind, root count, and detection flags.
|
|
1541
|
+
Use this to check graph health and staleness.
|
|
1542
|
+
"""
|
|
1543
|
+
return _get_graph_status(_state["graph"])
|
|
1544
|
+
|
|
1545
|
+
@mcp.tool()
|
|
1546
|
+
def refresh_graph(full: bool = False) -> dict[str, Any]:
|
|
1547
|
+
"""Force graph rebuild from spec files.
|
|
65
1548
|
|
|
66
|
-
|
|
67
|
-
|
|
1549
|
+
Args:
|
|
1550
|
+
full: If True, clear all caches before rebuild.
|
|
68
1551
|
|
|
69
|
-
|
|
70
|
-
|
|
1552
|
+
Returns:
|
|
1553
|
+
Success status and new node count.
|
|
71
1554
|
"""
|
|
72
|
-
|
|
1555
|
+
result, new_graph = _refresh_graph(_state["working_dir"], full=full)
|
|
1556
|
+
_state["graph"] = new_graph
|
|
1557
|
+
return result
|
|
1558
|
+
|
|
1559
|
+
@mcp.tool()
|
|
1560
|
+
def search(
|
|
1561
|
+
query: str,
|
|
1562
|
+
field: str = "all",
|
|
1563
|
+
regex: bool = False,
|
|
1564
|
+
limit: int = 50,
|
|
1565
|
+
) -> list[dict[str, Any]]:
|
|
1566
|
+
"""Search requirements by ID, title, or content.
|
|
1567
|
+
|
|
1568
|
+
Args:
|
|
1569
|
+
query: Search string or regex pattern.
|
|
1570
|
+
field: Field to search: 'id', 'title', 'body', or 'all'.
|
|
1571
|
+
regex: If True, treat query as regex pattern.
|
|
1572
|
+
limit: Maximum results to return (default 50).
|
|
73
1573
|
|
|
74
|
-
Returns
|
|
75
|
-
|
|
1574
|
+
Returns:
|
|
1575
|
+
List of matching requirement summaries.
|
|
76
1576
|
"""
|
|
77
|
-
|
|
1577
|
+
return _search(_state["graph"], query, field, regex, limit)
|
|
78
1578
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
],
|
|
86
|
-
},
|
|
87
|
-
indent=2,
|
|
88
|
-
)
|
|
1579
|
+
@mcp.tool()
|
|
1580
|
+
def get_requirement(req_id: str) -> dict[str, Any]:
|
|
1581
|
+
"""Get full details for a single requirement.
|
|
1582
|
+
|
|
1583
|
+
Args:
|
|
1584
|
+
req_id: The requirement ID (e.g., 'REQ-p00001').
|
|
89
1585
|
|
|
90
|
-
|
|
91
|
-
|
|
1586
|
+
Returns:
|
|
1587
|
+
Requirement details including assertions and relationships.
|
|
92
1588
|
"""
|
|
93
|
-
|
|
1589
|
+
return _get_requirement(_state["graph"], req_id)
|
|
1590
|
+
|
|
1591
|
+
@mcp.tool()
|
|
1592
|
+
def get_hierarchy(req_id: str) -> dict[str, Any]:
|
|
1593
|
+
"""Get requirement hierarchy (ancestors and children).
|
|
1594
|
+
|
|
1595
|
+
Args:
|
|
1596
|
+
req_id: The requirement ID.
|
|
94
1597
|
|
|
95
|
-
Returns
|
|
96
|
-
|
|
1598
|
+
Returns:
|
|
1599
|
+
Ancestors (walking up to roots) and direct children.
|
|
97
1600
|
"""
|
|
98
|
-
|
|
1601
|
+
return _get_hierarchy(_state["graph"], req_id)
|
|
99
1602
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return json.dumps(serialize_requirement(req), indent=2)
|
|
1603
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1604
|
+
# Workspace Context Tools (REQ-o00061)
|
|
1605
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
104
1606
|
|
|
105
|
-
@mcp.
|
|
106
|
-
def
|
|
107
|
-
"""Get
|
|
108
|
-
import json
|
|
1607
|
+
@mcp.tool()
|
|
1608
|
+
def get_workspace_info() -> dict[str, Any]:
|
|
1609
|
+
"""Get information about the current workspace.
|
|
109
1610
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return json.dumps(
|
|
113
|
-
{
|
|
114
|
-
"level": level,
|
|
115
|
-
"count": len(filtered),
|
|
116
|
-
"requirements": [serialize_requirement_summary(r) for r in filtered],
|
|
117
|
-
},
|
|
118
|
-
indent=2,
|
|
119
|
-
)
|
|
1611
|
+
Returns repository path, project name, and configuration summary.
|
|
1612
|
+
Use this to understand what project you're working with.
|
|
120
1613
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
"""
|
|
124
|
-
|
|
1614
|
+
Returns:
|
|
1615
|
+
Workspace information including repo path, project name, and config.
|
|
1616
|
+
"""
|
|
1617
|
+
return _get_workspace_info(_state["working_dir"])
|
|
125
1618
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
1619
|
+
@mcp.tool()
|
|
1620
|
+
def get_project_summary() -> dict[str, Any]:
|
|
1621
|
+
"""Get summary statistics for the project.
|
|
1622
|
+
|
|
1623
|
+
Returns requirement counts by level (PRD/OPS/DEV), coverage statistics,
|
|
1624
|
+
and change metrics (uncommitted, branch changed).
|
|
1625
|
+
|
|
1626
|
+
Returns:
|
|
1627
|
+
Project summary with counts, coverage, and change metrics.
|
|
1628
|
+
"""
|
|
1629
|
+
return _get_project_summary(_state["graph"], _state["working_dir"])
|
|
1630
|
+
|
|
1631
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1632
|
+
# Node Mutation Tools (REQ-o00062-A)
|
|
1633
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1634
|
+
|
|
1635
|
+
@mcp.tool()
|
|
1636
|
+
def mutate_rename_node(old_id: str, new_id: str) -> dict[str, Any]:
|
|
1637
|
+
"""Rename a requirement node.
|
|
1638
|
+
|
|
1639
|
+
Updates the node's ID and all references to it.
|
|
1640
|
+
|
|
1641
|
+
Args:
|
|
1642
|
+
old_id: Current node ID.
|
|
1643
|
+
new_id: New node ID.
|
|
1644
|
+
|
|
1645
|
+
Returns:
|
|
1646
|
+
Success status and mutation entry for undo.
|
|
1647
|
+
"""
|
|
1648
|
+
return _mutate_rename_node(_state["graph"], old_id, new_id)
|
|
1649
|
+
|
|
1650
|
+
@mcp.tool()
|
|
1651
|
+
def mutate_update_title(node_id: str, new_title: str) -> dict[str, Any]:
|
|
1652
|
+
"""Update a requirement's title.
|
|
1653
|
+
|
|
1654
|
+
Does not affect the content hash.
|
|
1655
|
+
|
|
1656
|
+
Args:
|
|
1657
|
+
node_id: The requirement ID.
|
|
1658
|
+
new_title: New title text.
|
|
1659
|
+
|
|
1660
|
+
Returns:
|
|
1661
|
+
Success status and mutation entry for undo.
|
|
1662
|
+
"""
|
|
1663
|
+
return _mutate_update_title(_state["graph"], node_id, new_title)
|
|
1664
|
+
|
|
1665
|
+
@mcp.tool()
|
|
1666
|
+
def mutate_change_status(node_id: str, new_status: str) -> dict[str, Any]:
|
|
1667
|
+
"""Change a requirement's status.
|
|
1668
|
+
|
|
1669
|
+
Args:
|
|
1670
|
+
node_id: The requirement ID.
|
|
1671
|
+
new_status: New status (e.g., 'Active', 'Draft', 'Deprecated').
|
|
1672
|
+
|
|
1673
|
+
Returns:
|
|
1674
|
+
Success status and mutation entry for undo.
|
|
1675
|
+
"""
|
|
1676
|
+
return _mutate_change_status(_state["graph"], node_id, new_status)
|
|
1677
|
+
|
|
1678
|
+
@mcp.tool()
|
|
1679
|
+
def mutate_add_requirement(
|
|
1680
|
+
req_id: str,
|
|
1681
|
+
title: str,
|
|
1682
|
+
level: str,
|
|
1683
|
+
status: str = "Draft",
|
|
1684
|
+
parent_id: str | None = None,
|
|
1685
|
+
edge_kind: str | None = None,
|
|
1686
|
+
) -> dict[str, Any]:
|
|
1687
|
+
"""Create a new requirement.
|
|
1688
|
+
|
|
1689
|
+
Args:
|
|
1690
|
+
req_id: ID for the new requirement.
|
|
1691
|
+
title: Requirement title.
|
|
1692
|
+
level: Level (PRD, OPS, DEV).
|
|
1693
|
+
status: Initial status (default 'Draft').
|
|
1694
|
+
parent_id: Optional parent requirement to link to.
|
|
1695
|
+
edge_kind: Edge type if parent_id set ('IMPLEMENTS' or 'REFINES').
|
|
1696
|
+
|
|
1697
|
+
Returns:
|
|
1698
|
+
Success status and mutation entry for undo.
|
|
1699
|
+
"""
|
|
1700
|
+
return _mutate_add_requirement(
|
|
1701
|
+
_state["graph"], req_id, title, level, status, parent_id, edge_kind
|
|
141
1702
|
)
|
|
142
1703
|
|
|
143
|
-
@mcp.
|
|
144
|
-
def
|
|
1704
|
+
@mcp.tool()
|
|
1705
|
+
def mutate_delete_requirement(node_id: str, confirm: bool = False) -> dict[str, Any]:
|
|
1706
|
+
"""Delete a requirement.
|
|
1707
|
+
|
|
1708
|
+
DESTRUCTIVE: Requires confirm=True to execute.
|
|
1709
|
+
|
|
1710
|
+
Args:
|
|
1711
|
+
node_id: The requirement ID to delete.
|
|
1712
|
+
confirm: Must be True to confirm deletion.
|
|
1713
|
+
|
|
1714
|
+
Returns:
|
|
1715
|
+
Success status and mutation entry for undo.
|
|
1716
|
+
"""
|
|
1717
|
+
return _mutate_delete_requirement(_state["graph"], node_id, confirm)
|
|
1718
|
+
|
|
1719
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1720
|
+
# Assertion Mutation Tools (REQ-o00062-B)
|
|
1721
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1722
|
+
|
|
1723
|
+
@mcp.tool()
|
|
1724
|
+
def mutate_add_assertion(req_id: str, label: str, text: str) -> dict[str, Any]:
|
|
1725
|
+
"""Add an assertion to a requirement.
|
|
1726
|
+
|
|
1727
|
+
Args:
|
|
1728
|
+
req_id: Parent requirement ID.
|
|
1729
|
+
label: Assertion label (e.g., 'A', 'B', 'C').
|
|
1730
|
+
text: Assertion text (SHALL statement).
|
|
1731
|
+
|
|
1732
|
+
Returns:
|
|
1733
|
+
Success status and mutation entry for undo.
|
|
145
1734
|
"""
|
|
146
|
-
|
|
1735
|
+
return _mutate_add_assertion(_state["graph"], req_id, label, text)
|
|
1736
|
+
|
|
1737
|
+
@mcp.tool()
|
|
1738
|
+
def mutate_update_assertion(assertion_id: str, new_text: str) -> dict[str, Any]:
|
|
1739
|
+
"""Update an assertion's text.
|
|
147
1740
|
|
|
148
|
-
|
|
149
|
-
|
|
1741
|
+
Recomputes the parent requirement's hash.
|
|
1742
|
+
|
|
1743
|
+
Args:
|
|
1744
|
+
assertion_id: The assertion ID (e.g., 'REQ-p00001-A').
|
|
1745
|
+
new_text: New assertion text.
|
|
1746
|
+
|
|
1747
|
+
Returns:
|
|
1748
|
+
Success status and mutation entry for undo.
|
|
150
1749
|
"""
|
|
151
|
-
|
|
1750
|
+
return _mutate_update_assertion(_state["graph"], assertion_id, new_text)
|
|
1751
|
+
|
|
1752
|
+
@mcp.tool()
|
|
1753
|
+
def mutate_delete_assertion(
|
|
1754
|
+
assertion_id: str, compact: bool = True, confirm: bool = False
|
|
1755
|
+
) -> dict[str, Any]:
|
|
1756
|
+
"""Delete an assertion.
|
|
1757
|
+
|
|
1758
|
+
DESTRUCTIVE: Requires confirm=True to execute.
|
|
1759
|
+
|
|
1760
|
+
Args:
|
|
1761
|
+
assertion_id: The assertion ID to delete.
|
|
1762
|
+
compact: If True, renumber subsequent assertions.
|
|
1763
|
+
confirm: Must be True to confirm deletion.
|
|
152
1764
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return json.dumps({"error": f"Content rule not found: {filename}"})
|
|
1765
|
+
Returns:
|
|
1766
|
+
Success status and mutation entry for undo.
|
|
1767
|
+
"""
|
|
1768
|
+
return _mutate_delete_assertion(_state["graph"], assertion_id, compact, confirm)
|
|
158
1769
|
|
|
159
|
-
@mcp.
|
|
160
|
-
def
|
|
161
|
-
"""
|
|
162
|
-
import json
|
|
1770
|
+
@mcp.tool()
|
|
1771
|
+
def mutate_rename_assertion(old_id: str, new_label: str) -> dict[str, Any]:
|
|
1772
|
+
"""Rename an assertion's label.
|
|
163
1773
|
|
|
164
|
-
|
|
1774
|
+
Args:
|
|
1775
|
+
old_id: Current assertion ID (e.g., 'REQ-p00001-A').
|
|
1776
|
+
new_label: New label (e.g., 'X').
|
|
165
1777
|
|
|
1778
|
+
Returns:
|
|
1779
|
+
Success status and mutation entry for undo.
|
|
1780
|
+
"""
|
|
1781
|
+
return _mutate_rename_assertion(_state["graph"], old_id, new_label)
|
|
166
1782
|
|
|
167
|
-
|
|
168
|
-
|
|
1783
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1784
|
+
# Edge Mutation Tools (REQ-o00062-C)
|
|
1785
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
169
1786
|
|
|
170
1787
|
@mcp.tool()
|
|
171
|
-
def
|
|
1788
|
+
def mutate_add_edge(
|
|
1789
|
+
source_id: str,
|
|
1790
|
+
target_id: str,
|
|
1791
|
+
edge_kind: str,
|
|
1792
|
+
assertion_targets: list[str] | None = None,
|
|
1793
|
+
) -> dict[str, Any]:
|
|
1794
|
+
"""Add an edge between nodes.
|
|
1795
|
+
|
|
1796
|
+
Args:
|
|
1797
|
+
source_id: Child node ID.
|
|
1798
|
+
target_id: Parent node ID.
|
|
1799
|
+
edge_kind: Relationship type ('IMPLEMENTS' or 'REFINES').
|
|
1800
|
+
assertion_targets: Optional list of assertion IDs to target.
|
|
1801
|
+
|
|
1802
|
+
Returns:
|
|
1803
|
+
Success status and mutation entry for undo.
|
|
172
1804
|
"""
|
|
173
|
-
|
|
1805
|
+
return _mutate_add_edge(_state["graph"], source_id, target_id, edge_kind, assertion_targets)
|
|
174
1806
|
|
|
175
|
-
|
|
176
|
-
|
|
1807
|
+
@mcp.tool()
|
|
1808
|
+
def mutate_change_edge_kind(source_id: str, target_id: str, new_kind: str) -> dict[str, Any]:
|
|
1809
|
+
"""Change an edge's relationship type.
|
|
177
1810
|
|
|
178
1811
|
Args:
|
|
179
|
-
|
|
1812
|
+
source_id: Child node ID.
|
|
1813
|
+
target_id: Parent node ID.
|
|
1814
|
+
new_kind: New relationship type ('IMPLEMENTS' or 'REFINES').
|
|
1815
|
+
|
|
1816
|
+
Returns:
|
|
1817
|
+
Success status and mutation entry for undo.
|
|
180
1818
|
"""
|
|
181
|
-
|
|
1819
|
+
return _mutate_change_edge_kind(_state["graph"], source_id, target_id, new_kind)
|
|
1820
|
+
|
|
1821
|
+
@mcp.tool()
|
|
1822
|
+
def mutate_delete_edge(source_id: str, target_id: str, confirm: bool = False) -> dict[str, Any]:
|
|
1823
|
+
"""Delete an edge between nodes.
|
|
182
1824
|
|
|
183
|
-
|
|
184
|
-
rules_config = RulesConfig.from_dict(ctx.config.get("rules", {}))
|
|
185
|
-
engine = RuleEngine(rules_config)
|
|
1825
|
+
DESTRUCTIVE: Requires confirm=True to execute.
|
|
186
1826
|
|
|
187
|
-
|
|
1827
|
+
Args:
|
|
1828
|
+
source_id: Child node ID.
|
|
1829
|
+
target_id: Parent node ID.
|
|
1830
|
+
confirm: Must be True to confirm deletion.
|
|
188
1831
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
1832
|
+
Returns:
|
|
1833
|
+
Success status and mutation entry for undo.
|
|
1834
|
+
"""
|
|
1835
|
+
return _mutate_delete_edge(_state["graph"], source_id, target_id, confirm)
|
|
192
1836
|
|
|
193
|
-
|
|
194
|
-
|
|
1837
|
+
@mcp.tool()
|
|
1838
|
+
def mutate_fix_broken_reference(
|
|
1839
|
+
source_id: str, old_target_id: str, new_target_id: str
|
|
1840
|
+
) -> dict[str, Any]:
|
|
1841
|
+
"""Fix a broken reference by redirecting to a valid target.
|
|
195
1842
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
1843
|
+
Args:
|
|
1844
|
+
source_id: Node with the broken reference.
|
|
1845
|
+
old_target_id: Invalid target ID.
|
|
1846
|
+
new_target_id: Valid target ID to redirect to.
|
|
1847
|
+
|
|
1848
|
+
Returns:
|
|
1849
|
+
Success status and mutation entry for undo.
|
|
1850
|
+
"""
|
|
1851
|
+
return _mutate_fix_broken_reference(
|
|
1852
|
+
_state["graph"], source_id, old_target_id, new_target_id
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1856
|
+
# Undo & Inspection Tools (REQ-o00062-G)
|
|
1857
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
205
1858
|
|
|
206
1859
|
@mcp.tool()
|
|
207
|
-
def
|
|
1860
|
+
def undo_last_mutation() -> dict[str, Any]:
|
|
1861
|
+
"""Undo the most recent mutation.
|
|
1862
|
+
|
|
1863
|
+
Returns:
|
|
1864
|
+
Success status and the mutation that was undone.
|
|
208
1865
|
"""
|
|
209
|
-
|
|
1866
|
+
return _undo_last_mutation(_state["graph"])
|
|
1867
|
+
|
|
1868
|
+
@mcp.tool()
|
|
1869
|
+
def undo_to_mutation(mutation_id: str) -> dict[str, Any]:
|
|
1870
|
+
"""Undo all mutations back to a specific point.
|
|
210
1871
|
|
|
211
1872
|
Args:
|
|
212
|
-
|
|
213
|
-
|
|
1873
|
+
mutation_id: ID of the mutation to undo back to (inclusive).
|
|
1874
|
+
|
|
1875
|
+
Returns:
|
|
1876
|
+
Success status and list of mutations undone.
|
|
214
1877
|
"""
|
|
215
|
-
|
|
216
|
-
from elspais.core.patterns import PatternConfig
|
|
1878
|
+
return _undo_to_mutation(_state["graph"], mutation_id)
|
|
217
1879
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
requirements = parser.parse_text(text, file_path=path)
|
|
1880
|
+
@mcp.tool()
|
|
1881
|
+
def get_mutation_log(limit: int = 50) -> dict[str, Any]:
|
|
1882
|
+
"""Get mutation history.
|
|
222
1883
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
1884
|
+
Args:
|
|
1885
|
+
limit: Maximum number of mutations to return.
|
|
1886
|
+
|
|
1887
|
+
Returns:
|
|
1888
|
+
List of recent mutations.
|
|
1889
|
+
"""
|
|
1890
|
+
return _get_mutation_log(_state["graph"], limit)
|
|
229
1891
|
|
|
230
1892
|
@mcp.tool()
|
|
231
|
-
def
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
1893
|
+
def get_orphaned_nodes() -> dict[str, Any]:
|
|
1894
|
+
"""Get all orphaned nodes.
|
|
1895
|
+
|
|
1896
|
+
Returns nodes that have no parent relationships.
|
|
1897
|
+
|
|
1898
|
+
Returns:
|
|
1899
|
+
List of orphaned nodes with summaries.
|
|
1900
|
+
"""
|
|
1901
|
+
return _get_orphaned_nodes(_state["graph"])
|
|
1902
|
+
|
|
1903
|
+
@mcp.tool()
|
|
1904
|
+
def get_broken_references() -> dict[str, Any]:
|
|
1905
|
+
"""Get all broken references.
|
|
1906
|
+
|
|
1907
|
+
Returns edges that point to non-existent nodes.
|
|
1908
|
+
|
|
1909
|
+
Returns:
|
|
1910
|
+
List of broken references.
|
|
236
1911
|
"""
|
|
237
|
-
|
|
1912
|
+
return _get_broken_references(_state["graph"])
|
|
1913
|
+
|
|
1914
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1915
|
+
# Keyword Search Tools (Phase 4)
|
|
1916
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1917
|
+
|
|
1918
|
+
@mcp.tool()
|
|
1919
|
+
def find_by_keywords(
|
|
1920
|
+
keywords: list[str],
|
|
1921
|
+
match_all: bool = True,
|
|
1922
|
+
) -> dict[str, Any]:
|
|
1923
|
+
"""Find requirements containing specified keywords.
|
|
1924
|
+
|
|
1925
|
+
Keywords are extracted from requirement titles and assertion text.
|
|
238
1926
|
|
|
239
1927
|
Args:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
1928
|
+
keywords: List of keywords to search for.
|
|
1929
|
+
match_all: If True, requirement must contain ALL keywords (AND).
|
|
1930
|
+
If False, requirement must contain ANY keyword (OR).
|
|
1931
|
+
|
|
1932
|
+
Returns:
|
|
1933
|
+
List of matching requirements with their summaries.
|
|
243
1934
|
"""
|
|
244
|
-
|
|
245
|
-
return {
|
|
246
|
-
"count": len(results),
|
|
247
|
-
"query": query,
|
|
248
|
-
"field": field,
|
|
249
|
-
"requirements": [serialize_requirement_summary(r) for r in results],
|
|
250
|
-
}
|
|
1935
|
+
return _find_by_keywords(_state["graph"], keywords, match_all)
|
|
251
1936
|
|
|
252
1937
|
@mcp.tool()
|
|
253
|
-
def
|
|
1938
|
+
def get_all_keywords() -> dict[str, Any]:
|
|
1939
|
+
"""Get all unique keywords from the graph.
|
|
1940
|
+
|
|
1941
|
+
Keywords are extracted from requirement titles and assertion text.
|
|
1942
|
+
Use this to discover available keywords for filtering.
|
|
1943
|
+
|
|
1944
|
+
Returns:
|
|
1945
|
+
Sorted list of all unique keywords and total count.
|
|
254
1946
|
"""
|
|
255
|
-
|
|
1947
|
+
return _get_all_keywords(_state["graph"])
|
|
1948
|
+
|
|
1949
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1950
|
+
# Test Coverage Tools (REQ-o00064)
|
|
1951
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
1952
|
+
|
|
1953
|
+
@mcp.tool()
|
|
1954
|
+
def get_test_coverage(req_id: str) -> dict[str, Any]:
|
|
1955
|
+
"""Get test coverage for a requirement.
|
|
1956
|
+
|
|
1957
|
+
Returns TEST nodes that reference the requirement and their TEST_RESULT nodes.
|
|
1958
|
+
Identifies assertion coverage gaps (assertions with no tests).
|
|
256
1959
|
|
|
257
1960
|
Args:
|
|
258
|
-
req_id: The requirement ID
|
|
1961
|
+
req_id: The requirement ID to get coverage for.
|
|
1962
|
+
|
|
1963
|
+
Returns:
|
|
1964
|
+
Test nodes, result nodes, covered/uncovered assertions, and coverage percentage.
|
|
259
1965
|
"""
|
|
260
|
-
|
|
261
|
-
if req is None:
|
|
262
|
-
return {"error": f"Requirement {req_id} not found"}
|
|
263
|
-
return serialize_requirement(req)
|
|
1966
|
+
return _get_test_coverage(_state["graph"], req_id)
|
|
264
1967
|
|
|
265
1968
|
@mcp.tool()
|
|
266
|
-
def
|
|
1969
|
+
def get_uncovered_assertions(req_id: str | None = None) -> dict[str, Any]:
|
|
1970
|
+
"""Get all assertions lacking test coverage.
|
|
1971
|
+
|
|
1972
|
+
Returns assertions that have no TEST node references.
|
|
1973
|
+
Include parent requirement context in results.
|
|
1974
|
+
|
|
1975
|
+
Args:
|
|
1976
|
+
req_id: Optional requirement ID. When None, scan all requirements.
|
|
1977
|
+
|
|
1978
|
+
Returns:
|
|
1979
|
+
List of uncovered assertions with their parent requirement context.
|
|
267
1980
|
"""
|
|
268
|
-
|
|
1981
|
+
return _get_uncovered_assertions(_state["graph"], req_id)
|
|
1982
|
+
|
|
1983
|
+
@mcp.tool()
|
|
1984
|
+
def find_assertions_by_keywords(
|
|
1985
|
+
keywords: list[str],
|
|
1986
|
+
match_all: bool = True,
|
|
1987
|
+
) -> dict[str, Any]:
|
|
1988
|
+
"""Find assertions containing specified keywords.
|
|
1989
|
+
|
|
1990
|
+
Search assertion text for matching keywords.
|
|
1991
|
+
Return assertion id, text, label, and parent requirement context.
|
|
1992
|
+
Complement to existing find_by_keywords() which finds requirements.
|
|
269
1993
|
|
|
270
1994
|
Args:
|
|
271
|
-
|
|
1995
|
+
keywords: List of keywords to search for.
|
|
1996
|
+
match_all: If True, assertion must contain ALL keywords (AND).
|
|
1997
|
+
If False, assertion must contain ANY keyword (OR).
|
|
1998
|
+
|
|
1999
|
+
Returns:
|
|
2000
|
+
List of matching assertions with their summaries.
|
|
272
2001
|
"""
|
|
273
|
-
|
|
2002
|
+
return _find_assertions_by_keywords(_state["graph"], keywords, match_all)
|
|
274
2003
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
return _analyze_orphans(requirements)
|
|
279
|
-
elif analysis_type == "coverage":
|
|
280
|
-
return _analyze_coverage(requirements)
|
|
281
|
-
else:
|
|
282
|
-
return {"error": f"Unknown analysis type: {analysis_type}"}
|
|
2004
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
2005
|
+
# File Mutation Tools (REQ-o00063)
|
|
2006
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
283
2007
|
|
|
2008
|
+
@mcp.tool()
|
|
2009
|
+
def change_reference_type(
|
|
2010
|
+
req_id: str,
|
|
2011
|
+
target_id: str,
|
|
2012
|
+
new_type: str,
|
|
2013
|
+
save_branch: bool = False,
|
|
2014
|
+
) -> dict[str, Any]:
|
|
2015
|
+
"""Change a reference type in a spec file.
|
|
284
2016
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
# Build parent -> children mapping
|
|
288
|
-
children_map: Dict[str, List[str]] = {}
|
|
289
|
-
roots = []
|
|
2017
|
+
Modifies Implements/Refines relationships in spec files on disk.
|
|
2018
|
+
Optionally creates a safety branch for rollback.
|
|
290
2019
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if parent_id not in children_map:
|
|
297
|
-
children_map[parent_id] = []
|
|
298
|
-
children_map[parent_id].append(req.id)
|
|
2020
|
+
Args:
|
|
2021
|
+
req_id: ID of the requirement to modify.
|
|
2022
|
+
target_id: ID of the target requirement being referenced.
|
|
2023
|
+
new_type: New reference type ('IMPLEMENTS' or 'REFINES').
|
|
2024
|
+
save_branch: If True, create a safety branch before modifying.
|
|
299
2025
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
"
|
|
303
|
-
|
|
304
|
-
|
|
2026
|
+
Returns:
|
|
2027
|
+
Success status and optional safety_branch name.
|
|
2028
|
+
"""
|
|
2029
|
+
result = _change_reference_type(
|
|
2030
|
+
_state["working_dir"], req_id, target_id, new_type, save_branch
|
|
2031
|
+
)
|
|
2032
|
+
# REQ-o00063-F: Refresh graph after file mutations
|
|
2033
|
+
if result.get("success"):
|
|
2034
|
+
new_result, new_graph = _refresh_graph(_state["working_dir"])
|
|
2035
|
+
_state["graph"] = new_graph
|
|
2036
|
+
return result
|
|
305
2037
|
|
|
2038
|
+
@mcp.tool()
|
|
2039
|
+
def move_requirement(
|
|
2040
|
+
req_id: str,
|
|
2041
|
+
target_file: str,
|
|
2042
|
+
save_branch: bool = False,
|
|
2043
|
+
) -> dict[str, Any]:
|
|
2044
|
+
"""Move a requirement to a different spec file.
|
|
306
2045
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
all_ids = set(requirements.keys())
|
|
310
|
-
orphans = []
|
|
2046
|
+
Relocates a requirement from its current file to the target file.
|
|
2047
|
+
Optionally creates a safety branch for rollback.
|
|
311
2048
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
{
|
|
317
|
-
"id": req.id,
|
|
318
|
-
"missing_parent": parent_id,
|
|
319
|
-
}
|
|
320
|
-
)
|
|
2049
|
+
Args:
|
|
2050
|
+
req_id: ID of the requirement to move.
|
|
2051
|
+
target_file: Relative path to the target file (e.g., 'spec/other.md').
|
|
2052
|
+
save_branch: If True, create a safety branch before modifying.
|
|
321
2053
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
"
|
|
325
|
-
|
|
2054
|
+
Returns:
|
|
2055
|
+
Success status and optional safety_branch name.
|
|
2056
|
+
"""
|
|
2057
|
+
result = _move_requirement(_state["working_dir"], req_id, target_file, save_branch)
|
|
2058
|
+
# REQ-o00063-F: Refresh graph after file mutations
|
|
2059
|
+
if result.get("success"):
|
|
2060
|
+
new_result, new_graph = _refresh_graph(_state["working_dir"])
|
|
2061
|
+
_state["graph"] = new_graph
|
|
2062
|
+
return result
|
|
326
2063
|
|
|
2064
|
+
@mcp.tool()
|
|
2065
|
+
def restore_from_safety_branch(branch_name: str) -> dict[str, Any]:
|
|
2066
|
+
"""Restore spec files from a safety branch.
|
|
327
2067
|
|
|
328
|
-
|
|
329
|
-
"""Analyze requirement coverage by level."""
|
|
330
|
-
levels: Dict[str, int] = {}
|
|
2068
|
+
Reverts file changes by restoring from a previously created safety branch.
|
|
331
2069
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
levels[level] = levels.get(level, 0) + 1
|
|
2070
|
+
Args:
|
|
2071
|
+
branch_name: Name of the safety branch to restore from.
|
|
335
2072
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
"
|
|
339
|
-
|
|
2073
|
+
Returns:
|
|
2074
|
+
Success status and list of files restored.
|
|
2075
|
+
"""
|
|
2076
|
+
result = _restore_from_safety_branch(_state["working_dir"], branch_name)
|
|
2077
|
+
# REQ-o00063-F: Refresh graph after file mutations
|
|
2078
|
+
if result.get("success"):
|
|
2079
|
+
new_result, new_graph = _refresh_graph(_state["working_dir"])
|
|
2080
|
+
_state["graph"] = new_graph
|
|
2081
|
+
return result
|
|
2082
|
+
|
|
2083
|
+
@mcp.tool()
|
|
2084
|
+
def list_safety_branches() -> dict[str, Any]:
|
|
2085
|
+
"""List all safety branches.
|
|
2086
|
+
|
|
2087
|
+
Returns available safety branches that can be used with restore_from_safety_branch.
|
|
2088
|
+
|
|
2089
|
+
Returns:
|
|
2090
|
+
List of branch names and count.
|
|
2091
|
+
"""
|
|
2092
|
+
return _list_safety_branches_impl(_state["working_dir"])
|
|
2093
|
+
|
|
2094
|
+
return mcp
|
|
340
2095
|
|
|
341
2096
|
|
|
342
2097
|
def run_server(
|
|
343
|
-
working_dir:
|
|
2098
|
+
working_dir: Path | None = None,
|
|
344
2099
|
transport: str = "stdio",
|
|
345
2100
|
) -> None:
|
|
346
|
-
"""
|
|
347
|
-
Run the MCP server.
|
|
2101
|
+
"""Run the MCP server.
|
|
348
2102
|
|
|
349
2103
|
Args:
|
|
350
|
-
working_dir: Working directory
|
|
351
|
-
transport: Transport type
|
|
2104
|
+
working_dir: Working directory for graph building.
|
|
2105
|
+
transport: Transport type ('stdio' or 'sse').
|
|
352
2106
|
"""
|
|
353
|
-
mcp = create_server(working_dir)
|
|
2107
|
+
mcp = create_server(working_dir=working_dir)
|
|
354
2108
|
mcp.run(transport=transport)
|