elspais 0.11.2__py3-none-any.whl → 0.43.5__py3-none-any.whl

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