elspais 0.9.3__py3-none-any.whl → 0.11.0__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/cli.py +99 -1
- elspais/commands/hash_cmd.py +72 -26
- elspais/commands/reformat_cmd.py +458 -0
- elspais/commands/trace.py +157 -3
- elspais/commands/validate.py +44 -16
- elspais/core/models.py +2 -0
- elspais/core/parser.py +68 -24
- elspais/reformat/__init__.py +50 -0
- elspais/reformat/detector.py +119 -0
- elspais/reformat/hierarchy.py +246 -0
- elspais/reformat/line_breaks.py +220 -0
- elspais/reformat/prompts.py +123 -0
- elspais/reformat/transformer.py +264 -0
- elspais/sponsors/__init__.py +432 -0
- elspais/trace_view/__init__.py +54 -0
- elspais/trace_view/coverage.py +183 -0
- elspais/trace_view/generators/__init__.py +12 -0
- elspais/trace_view/generators/base.py +329 -0
- elspais/trace_view/generators/csv.py +122 -0
- elspais/trace_view/generators/markdown.py +175 -0
- elspais/trace_view/html/__init__.py +31 -0
- elspais/trace_view/html/generator.py +1006 -0
- elspais/trace_view/html/templates/base.html +283 -0
- elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
- elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
- elspais/trace_view/html/templates/components/legend_modal.html +69 -0
- elspais/trace_view/html/templates/components/review_panel.html +118 -0
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
- elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
- elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
- elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
- elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
- elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
- elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
- elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
- elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
- elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
- elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
- elspais/trace_view/html/templates/partials/scripts.js +1741 -0
- elspais/trace_view/html/templates/partials/styles.css +1756 -0
- elspais/trace_view/models.py +353 -0
- elspais/trace_view/review/__init__.py +60 -0
- elspais/trace_view/review/branches.py +1149 -0
- elspais/trace_view/review/models.py +1205 -0
- elspais/trace_view/review/position.py +609 -0
- elspais/trace_view/review/server.py +1056 -0
- elspais/trace_view/review/status.py +470 -0
- elspais/trace_view/review/storage.py +1367 -0
- elspais/trace_view/scanning.py +213 -0
- elspais/trace_view/specs/README.md +84 -0
- elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
- elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
- elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
- elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
- elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
- elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
- elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
- elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
- elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
- elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
- elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
- elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
- elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
- elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
- {elspais-0.9.3.dist-info → elspais-0.11.0.dist-info}/METADATA +33 -18
- elspais-0.11.0.dist-info/RECORD +101 -0
- elspais-0.9.3.dist-info/RECORD +0 -40
- {elspais-0.9.3.dist-info → elspais-0.11.0.dist-info}/WHEEL +0 -0
- {elspais-0.9.3.dist-info → elspais-0.11.0.dist-info}/entry_points.txt +0 -0
- {elspais-0.9.3.dist-info → elspais-0.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Status Modifier Module - Modify REQ status in spec files
|
|
4
|
+
|
|
5
|
+
Provides functions to change the status field of requirements in spec/*.md files.
|
|
6
|
+
Supports finding requirements, reading status, changing status atomically,
|
|
7
|
+
and updating content hashes.
|
|
8
|
+
|
|
9
|
+
IMPLEMENTS REQUIREMENTS:
|
|
10
|
+
REQ-tv-d00015: Status Modifier
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import tempfile
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional, Tuple
|
|
20
|
+
|
|
21
|
+
# =============================================================================
|
|
22
|
+
# Constants
|
|
23
|
+
# REQ-tv-d00015-D: Status values SHALL be validated against allowed set
|
|
24
|
+
# =============================================================================
|
|
25
|
+
|
|
26
|
+
VALID_STATUSES = {"Draft", "Active", "Deprecated"}
|
|
27
|
+
|
|
28
|
+
# Regex pattern to match the status line in a requirement
|
|
29
|
+
# Matches: **Level**: Dev | **Status**: Draft | **Implements**: REQ-xxx
|
|
30
|
+
STATUS_LINE_PATTERN = re.compile(
|
|
31
|
+
r'^(\*\*Level\*\*:\s+(?:PRD|Ops|Dev)\s+\|\s+'
|
|
32
|
+
r'\*\*Status\*\*:\s+)(Draft|Active|Deprecated)'
|
|
33
|
+
r'(\s+\|\s+\*\*Implements\*\*:\s+[^\n]*?)$',
|
|
34
|
+
re.MULTILINE
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Pattern to find a REQ header (supports both REQ-tv-xxx and REQ-SPONSOR-xxx formats)
|
|
38
|
+
REQ_HEADER_PATTERN = re.compile(
|
|
39
|
+
r'^#{1,6}\s+REQ-(?:([A-Za-z]{2,4})-)?([pod]\d{5}):\s+(.+)$',
|
|
40
|
+
re.MULTILINE
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Pattern to find the End footer with hash
|
|
44
|
+
REQ_FOOTER_PATTERN = re.compile(
|
|
45
|
+
r'^\*End\* \*([^*]+)\* \| \*\*Hash\*\*: ([a-f0-9]{8})$',
|
|
46
|
+
re.MULTILINE
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# =============================================================================
|
|
51
|
+
# Data Classes
|
|
52
|
+
# REQ-tv-d00015-A: Return structured location information
|
|
53
|
+
# =============================================================================
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ReqLocation:
|
|
57
|
+
"""Location of a requirement in a spec file."""
|
|
58
|
+
file_path: Path
|
|
59
|
+
line_number: int # 1-based line number of status line
|
|
60
|
+
current_status: str
|
|
61
|
+
req_id: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# =============================================================================
|
|
65
|
+
# Hash Functions
|
|
66
|
+
# REQ-tv-d00015-F: Content hash computation and update
|
|
67
|
+
# =============================================================================
|
|
68
|
+
|
|
69
|
+
def compute_req_hash(content: str) -> str:
|
|
70
|
+
"""
|
|
71
|
+
Compute an 8-character hex hash of requirement content.
|
|
72
|
+
|
|
73
|
+
REQ-tv-d00015-F: The status modifier SHALL update the requirement's
|
|
74
|
+
content hash footer after status changes.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
content: The content to hash
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
8-character lowercase hex string
|
|
81
|
+
"""
|
|
82
|
+
return hashlib.sha256(content.encode('utf-8')).hexdigest()[:8]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract_req_content(file_content: str, req_id: str) -> Optional[Tuple[str, int, int]]:
|
|
86
|
+
"""
|
|
87
|
+
Extract the content of a requirement from the file.
|
|
88
|
+
|
|
89
|
+
Returns the content between header and footer (exclusive of footer hash),
|
|
90
|
+
along with the footer start position and hash start position.
|
|
91
|
+
"""
|
|
92
|
+
# Normalize req_id (remove REQ- prefix if present)
|
|
93
|
+
normalized_id = req_id
|
|
94
|
+
if normalized_id.startswith("REQ-"):
|
|
95
|
+
normalized_id = normalized_id[4:]
|
|
96
|
+
|
|
97
|
+
# Build pattern for this specific requirement
|
|
98
|
+
# Handle both "tv-d00010" and "HHT-d00001" formats
|
|
99
|
+
if '-' in normalized_id:
|
|
100
|
+
parts = normalized_id.split('-', 1)
|
|
101
|
+
prefix = parts[0]
|
|
102
|
+
base_id = parts[1]
|
|
103
|
+
header_pattern = re.compile(
|
|
104
|
+
rf'^#{{1,6}}\s+REQ-{re.escape(prefix)}-{re.escape(base_id)}:\s+(.+)$',
|
|
105
|
+
re.MULTILINE
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
header_pattern = re.compile(
|
|
109
|
+
rf'^#{{1,6}}\s+REQ-{re.escape(normalized_id)}:\s+(.+)$',
|
|
110
|
+
re.MULTILINE
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
header_match = header_pattern.search(file_content)
|
|
114
|
+
if not header_match:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
header_start = header_match.start()
|
|
118
|
+
req_title = header_match.group(1).strip()
|
|
119
|
+
|
|
120
|
+
# Find the footer for this requirement
|
|
121
|
+
# The footer format is: *End* *{title}* | **Hash**: {hash}
|
|
122
|
+
footer_pattern = re.compile(
|
|
123
|
+
rf'^\*End\* \*{re.escape(req_title)}\* \| \*\*Hash\*\*: ([a-f0-9]{{8}})$',
|
|
124
|
+
re.MULTILINE
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Search from after the header
|
|
128
|
+
footer_match = footer_pattern.search(file_content, header_match.end())
|
|
129
|
+
if not footer_match:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
# Content is from header start to just before the hash value
|
|
133
|
+
footer_start = footer_match.start()
|
|
134
|
+
hash_start = footer_match.start(1)
|
|
135
|
+
|
|
136
|
+
# Content for hashing: everything from header to before the hash value
|
|
137
|
+
# This includes the "*End* *Title* | **Hash**: " but not the actual hash
|
|
138
|
+
content = file_content[header_start:hash_start]
|
|
139
|
+
|
|
140
|
+
return content, footer_start, hash_start
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def update_req_hash(file_path: Path, req_id: str) -> bool:
|
|
144
|
+
"""
|
|
145
|
+
Update the content hash for a requirement in a spec file.
|
|
146
|
+
|
|
147
|
+
REQ-tv-d00015-F: The status modifier SHALL update the requirement's
|
|
148
|
+
content hash footer after status changes.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
file_path: Path to the spec file
|
|
152
|
+
req_id: The requirement ID (with or without REQ- prefix)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if hash was updated, False if requirement not found
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
content = file_path.read_text(encoding='utf-8')
|
|
159
|
+
except (FileNotFoundError, IOError):
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
result = _extract_req_content(content, req_id)
|
|
163
|
+
if result is None:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
req_content, footer_start, hash_start = result
|
|
167
|
+
|
|
168
|
+
# Compute new hash
|
|
169
|
+
new_hash = compute_req_hash(req_content)
|
|
170
|
+
|
|
171
|
+
# Find the end of the old hash (8 characters after hash_start)
|
|
172
|
+
hash_end = hash_start + 8
|
|
173
|
+
|
|
174
|
+
# Replace the hash
|
|
175
|
+
new_content = content[:hash_start] + new_hash + content[hash_end:]
|
|
176
|
+
|
|
177
|
+
# Write atomically
|
|
178
|
+
_atomic_write_file(file_path, new_content)
|
|
179
|
+
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# =============================================================================
|
|
184
|
+
# File Search Functions
|
|
185
|
+
# REQ-tv-d00015-A: find_req_in_file() SHALL locate a requirement
|
|
186
|
+
# =============================================================================
|
|
187
|
+
|
|
188
|
+
def find_req_in_file(file_path: Path, req_id: str) -> Optional[ReqLocation]:
|
|
189
|
+
"""
|
|
190
|
+
Find a requirement in a spec file and return its position info.
|
|
191
|
+
|
|
192
|
+
REQ-tv-d00015-A: find_req_in_file(file_path, req_id) SHALL locate a
|
|
193
|
+
requirement in a spec file and return the status line information.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
file_path: Path to the spec file
|
|
197
|
+
req_id: The requirement ID (with or without REQ- prefix),
|
|
198
|
+
e.g., "tv-d00001" or "REQ-tv-d00001" or "HHT-d00001"
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
ReqLocation with req info if found, None otherwise
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
content = file_path.read_text(encoding='utf-8')
|
|
205
|
+
except (FileNotFoundError, IOError):
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
# Normalize req_id (remove REQ- prefix if present)
|
|
209
|
+
normalized_id = req_id
|
|
210
|
+
if normalized_id.startswith("REQ-"):
|
|
211
|
+
normalized_id = normalized_id[4:]
|
|
212
|
+
|
|
213
|
+
# Build pattern for this specific requirement
|
|
214
|
+
# Handle both "tv-d00010" and "HHT-d00001" formats
|
|
215
|
+
if '-' in normalized_id:
|
|
216
|
+
parts = normalized_id.split('-', 1)
|
|
217
|
+
prefix = parts[0]
|
|
218
|
+
base_id = parts[1]
|
|
219
|
+
header_pattern = re.compile(
|
|
220
|
+
rf'^#{{1,6}}\s+REQ-{re.escape(prefix)}-{re.escape(base_id)}:\s+.+$',
|
|
221
|
+
re.MULTILINE
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
header_pattern = re.compile(
|
|
225
|
+
rf'^#{{1,6}}\s+REQ-{re.escape(normalized_id)}:\s+.+$',
|
|
226
|
+
re.MULTILINE
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
header_match = header_pattern.search(content)
|
|
230
|
+
if not header_match:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
# Find the status line after this header
|
|
234
|
+
# Search from after the header to the next REQ or end of file
|
|
235
|
+
search_start = header_match.end()
|
|
236
|
+
|
|
237
|
+
# Find the next REQ header to limit our search
|
|
238
|
+
next_req_match = REQ_HEADER_PATTERN.search(content, search_start)
|
|
239
|
+
search_end = next_req_match.start() if next_req_match else len(content)
|
|
240
|
+
|
|
241
|
+
# Search for the status line within this range
|
|
242
|
+
status_match = STATUS_LINE_PATTERN.search(content, search_start, search_end)
|
|
243
|
+
if not status_match:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
current_status = status_match.group(2)
|
|
247
|
+
|
|
248
|
+
# Calculate 1-based line number
|
|
249
|
+
line_number = content[:status_match.start()].count('\n') + 1
|
|
250
|
+
|
|
251
|
+
return ReqLocation(
|
|
252
|
+
file_path=file_path,
|
|
253
|
+
line_number=line_number,
|
|
254
|
+
current_status=current_status,
|
|
255
|
+
req_id=normalized_id
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def find_req_in_spec_dir(repo_root: Path, req_id: str) -> Optional[ReqLocation]:
|
|
260
|
+
"""
|
|
261
|
+
Find which spec file contains a given requirement.
|
|
262
|
+
|
|
263
|
+
Searches both core spec/ directory and sponsor/*/spec/ directories.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
repo_root: Path to the repository root
|
|
267
|
+
req_id: The requirement ID (with or without REQ- prefix)
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
ReqLocation if found, None otherwise
|
|
271
|
+
"""
|
|
272
|
+
# Check core spec directory
|
|
273
|
+
spec_dir = repo_root / "spec"
|
|
274
|
+
if spec_dir.exists():
|
|
275
|
+
for spec_file in spec_dir.glob("*.md"):
|
|
276
|
+
if spec_file.name in ('INDEX.md', 'README.md', 'requirements-format.md'):
|
|
277
|
+
continue
|
|
278
|
+
location = find_req_in_file(spec_file, req_id)
|
|
279
|
+
if location:
|
|
280
|
+
return location
|
|
281
|
+
|
|
282
|
+
# Check sponsor spec directories
|
|
283
|
+
sponsor_dir = repo_root / "sponsor"
|
|
284
|
+
if sponsor_dir.exists():
|
|
285
|
+
for sponsor in sponsor_dir.iterdir():
|
|
286
|
+
if sponsor.is_dir():
|
|
287
|
+
sponsor_spec = sponsor / "spec"
|
|
288
|
+
if sponsor_spec.exists():
|
|
289
|
+
for spec_file in sponsor_spec.glob("*.md"):
|
|
290
|
+
if spec_file.name in ('INDEX.md', 'README.md', 'requirements-format.md'):
|
|
291
|
+
continue
|
|
292
|
+
location = find_req_in_file(spec_file, req_id)
|
|
293
|
+
if location:
|
|
294
|
+
return location
|
|
295
|
+
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# =============================================================================
|
|
300
|
+
# Status Read Function
|
|
301
|
+
# REQ-tv-d00015-B: get_req_status() SHALL read and return current status
|
|
302
|
+
# =============================================================================
|
|
303
|
+
|
|
304
|
+
def get_req_status(repo_root: Path, req_id: str) -> Optional[str]:
|
|
305
|
+
"""
|
|
306
|
+
Get the current status of a requirement.
|
|
307
|
+
|
|
308
|
+
REQ-tv-d00015-B: get_req_status(repo_root, req_id) SHALL read and return
|
|
309
|
+
the current status value from the spec file.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
repo_root: Path to the repository root
|
|
313
|
+
req_id: The requirement ID (with or without REQ- prefix)
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
The status string if found, None otherwise
|
|
317
|
+
"""
|
|
318
|
+
location = find_req_in_spec_dir(repo_root, req_id)
|
|
319
|
+
if not location:
|
|
320
|
+
return None
|
|
321
|
+
return location.current_status
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# =============================================================================
|
|
325
|
+
# Atomic File Operations
|
|
326
|
+
# REQ-tv-d00015-G: Failed status changes SHALL NOT corrupt the file
|
|
327
|
+
# =============================================================================
|
|
328
|
+
|
|
329
|
+
def _atomic_write_file(file_path: Path, content: str) -> None:
|
|
330
|
+
"""
|
|
331
|
+
Atomically write content to a file.
|
|
332
|
+
|
|
333
|
+
REQ-tv-d00015-G: Uses temp file + rename pattern to ensure file is either
|
|
334
|
+
fully written or not changed at all.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
file_path: Target file path
|
|
338
|
+
content: Content to write
|
|
339
|
+
"""
|
|
340
|
+
# Ensure parent directories exist
|
|
341
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
|
|
343
|
+
# Write to temp file in same directory (for atomic rename)
|
|
344
|
+
fd, temp_path = tempfile.mkstemp(
|
|
345
|
+
suffix='.md',
|
|
346
|
+
prefix='.tmp_',
|
|
347
|
+
dir=file_path.parent
|
|
348
|
+
)
|
|
349
|
+
try:
|
|
350
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
351
|
+
f.write(content)
|
|
352
|
+
# Atomic rename
|
|
353
|
+
os.rename(temp_path, file_path)
|
|
354
|
+
except Exception:
|
|
355
|
+
# Clean up temp file on failure
|
|
356
|
+
if os.path.exists(temp_path):
|
|
357
|
+
os.unlink(temp_path)
|
|
358
|
+
raise
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# =============================================================================
|
|
362
|
+
# Status Change Function
|
|
363
|
+
# REQ-tv-d00015-C: change_req_status() SHALL update status atomically
|
|
364
|
+
# =============================================================================
|
|
365
|
+
|
|
366
|
+
def change_req_status(
|
|
367
|
+
repo_root: Path,
|
|
368
|
+
req_id: str,
|
|
369
|
+
new_status: str,
|
|
370
|
+
user: str
|
|
371
|
+
) -> Tuple[bool, str]:
|
|
372
|
+
"""
|
|
373
|
+
Change the status of a requirement in its spec file.
|
|
374
|
+
|
|
375
|
+
REQ-tv-d00015-C: change_req_status(repo_root, req_id, new_status, user)
|
|
376
|
+
SHALL update the status value in the spec file atomically.
|
|
377
|
+
|
|
378
|
+
REQ-tv-d00015-D: Status values SHALL be validated against the allowed set.
|
|
379
|
+
|
|
380
|
+
REQ-tv-d00015-E: The status modifier SHALL preserve all other content.
|
|
381
|
+
|
|
382
|
+
REQ-tv-d00015-F: The status modifier SHALL update the requirement's
|
|
383
|
+
content hash footer after status changes.
|
|
384
|
+
|
|
385
|
+
REQ-tv-d00015-G: Failed status changes SHALL NOT leave the spec file
|
|
386
|
+
in a corrupted or partial state.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
repo_root: Path to the repository root
|
|
390
|
+
req_id: The requirement ID (with or without REQ- prefix)
|
|
391
|
+
new_status: The new status to set
|
|
392
|
+
user: Username making the change (for logging/audit)
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Tuple of (success: bool, message: str)
|
|
396
|
+
"""
|
|
397
|
+
# Validate new_status (REQ-tv-d00015-D)
|
|
398
|
+
if new_status not in VALID_STATUSES:
|
|
399
|
+
valid_list = ', '.join(sorted(VALID_STATUSES))
|
|
400
|
+
return (False, f"Invalid status '{new_status}'. Valid statuses: {valid_list}")
|
|
401
|
+
|
|
402
|
+
# Find the requirement
|
|
403
|
+
location = find_req_in_spec_dir(repo_root, req_id)
|
|
404
|
+
if not location:
|
|
405
|
+
return (False, f"REQ-{req_id} not found in any spec file")
|
|
406
|
+
|
|
407
|
+
# Check if already at target status
|
|
408
|
+
if location.current_status == new_status:
|
|
409
|
+
return (True, f"REQ-{req_id} already has status '{new_status}'")
|
|
410
|
+
|
|
411
|
+
# Read the file content
|
|
412
|
+
try:
|
|
413
|
+
content = location.file_path.read_text(encoding='utf-8')
|
|
414
|
+
except IOError as e:
|
|
415
|
+
return (False, f"Failed to read spec file: {e}")
|
|
416
|
+
|
|
417
|
+
# Normalize req_id for pattern matching
|
|
418
|
+
normalized_id = req_id
|
|
419
|
+
if normalized_id.startswith("REQ-"):
|
|
420
|
+
normalized_id = normalized_id[4:]
|
|
421
|
+
|
|
422
|
+
# Build pattern for this specific requirement header
|
|
423
|
+
if '-' in normalized_id:
|
|
424
|
+
parts = normalized_id.split('-', 1)
|
|
425
|
+
prefix = parts[0]
|
|
426
|
+
base_id = parts[1]
|
|
427
|
+
header_pattern = re.compile(
|
|
428
|
+
rf'^#{{1,6}}\s+REQ-{re.escape(prefix)}-{re.escape(base_id)}:\s+.+$',
|
|
429
|
+
re.MULTILINE
|
|
430
|
+
)
|
|
431
|
+
else:
|
|
432
|
+
header_pattern = re.compile(
|
|
433
|
+
rf'^#{{1,6}}\s+REQ-{re.escape(normalized_id)}:\s+.+$',
|
|
434
|
+
re.MULTILINE
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
header_match = header_pattern.search(content)
|
|
438
|
+
if not header_match:
|
|
439
|
+
return (False, f"REQ-{req_id} header not found in {location.file_path}")
|
|
440
|
+
|
|
441
|
+
# Find the status line
|
|
442
|
+
search_start = header_match.end()
|
|
443
|
+
next_req_match = REQ_HEADER_PATTERN.search(content, search_start)
|
|
444
|
+
search_end = next_req_match.start() if next_req_match else len(content)
|
|
445
|
+
|
|
446
|
+
status_match = STATUS_LINE_PATTERN.search(content, search_start, search_end)
|
|
447
|
+
if not status_match:
|
|
448
|
+
return (False, f"Status line not found for REQ-{req_id}")
|
|
449
|
+
|
|
450
|
+
# Build the new status line (REQ-tv-d00015-E: preserve formatting)
|
|
451
|
+
new_line = status_match.group(1) + new_status + status_match.group(3)
|
|
452
|
+
|
|
453
|
+
# Replace the status line in content
|
|
454
|
+
new_content = (
|
|
455
|
+
content[:status_match.start()] +
|
|
456
|
+
new_line +
|
|
457
|
+
content[status_match.end():]
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Write atomically (REQ-tv-d00015-G)
|
|
461
|
+
try:
|
|
462
|
+
_atomic_write_file(location.file_path, new_content)
|
|
463
|
+
except IOError as e:
|
|
464
|
+
return (False, f"Failed to write spec file: {e}")
|
|
465
|
+
|
|
466
|
+
# Update the hash (REQ-tv-d00015-F)
|
|
467
|
+
update_req_hash(location.file_path, req_id)
|
|
468
|
+
|
|
469
|
+
old_status = location.current_status
|
|
470
|
+
return (True, f"Changed REQ-{req_id} status from '{old_status}' to '{new_status}'")
|