elspais 0.11.0__py3-none-any.whl → 0.11.2__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 -1
- elspais/cli.py +75 -23
- elspais/commands/analyze.py +5 -6
- elspais/commands/changed.py +2 -6
- elspais/commands/config_cmd.py +4 -4
- elspais/commands/edit.py +32 -36
- elspais/commands/hash_cmd.py +24 -18
- elspais/commands/index.py +8 -7
- elspais/commands/init.py +4 -4
- elspais/commands/reformat_cmd.py +32 -43
- elspais/commands/rules_cmd.py +6 -2
- elspais/commands/trace.py +23 -19
- elspais/commands/validate.py +8 -10
- elspais/config/defaults.py +7 -1
- elspais/core/content_rules.py +0 -1
- elspais/core/git.py +4 -10
- elspais/core/parser.py +55 -56
- elspais/core/patterns.py +2 -6
- elspais/core/rules.py +10 -15
- elspais/mcp/__init__.py +2 -0
- elspais/mcp/context.py +1 -0
- elspais/mcp/serializers.py +1 -1
- elspais/mcp/server.py +54 -39
- elspais/reformat/__init__.py +13 -13
- elspais/reformat/detector.py +9 -16
- elspais/reformat/hierarchy.py +8 -7
- elspais/reformat/line_breaks.py +36 -38
- elspais/reformat/prompts.py +22 -12
- elspais/reformat/transformer.py +43 -41
- elspais/sponsors/__init__.py +0 -2
- elspais/testing/__init__.py +1 -1
- elspais/testing/result_parser.py +25 -21
- elspais/trace_view/__init__.py +4 -3
- elspais/trace_view/coverage.py +5 -5
- elspais/trace_view/generators/__init__.py +1 -1
- elspais/trace_view/generators/base.py +17 -12
- elspais/trace_view/generators/csv.py +2 -6
- elspais/trace_view/generators/markdown.py +3 -8
- elspais/trace_view/html/__init__.py +4 -2
- elspais/trace_view/html/generator.py +423 -289
- elspais/trace_view/models.py +25 -0
- elspais/trace_view/review/__init__.py +21 -18
- elspais/trace_view/review/branches.py +114 -121
- elspais/trace_view/review/models.py +232 -237
- elspais/trace_view/review/position.py +53 -71
- elspais/trace_view/review/server.py +264 -288
- elspais/trace_view/review/status.py +43 -58
- elspais/trace_view/review/storage.py +48 -72
- {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/METADATA +12 -9
- {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/RECORD +53 -53
- {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/WHEEL +0 -0
- {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/entry_points.txt +0 -0
- {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -28,22 +28,20 @@ VALID_STATUSES = {"Draft", "Active", "Deprecated"}
|
|
|
28
28
|
# Regex pattern to match the status line in a requirement
|
|
29
29
|
# Matches: **Level**: Dev | **Status**: Draft | **Implements**: REQ-xxx
|
|
30
30
|
STATUS_LINE_PATTERN = re.compile(
|
|
31
|
-
r
|
|
32
|
-
r
|
|
33
|
-
r
|
|
34
|
-
re.MULTILINE
|
|
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
35
|
)
|
|
36
36
|
|
|
37
37
|
# Pattern to find a REQ header (supports both REQ-tv-xxx and REQ-SPONSOR-xxx formats)
|
|
38
38
|
REQ_HEADER_PATTERN = re.compile(
|
|
39
|
-
r
|
|
40
|
-
re.MULTILINE
|
|
39
|
+
r"^#{1,6}\s+REQ-(?:([A-Za-z]{2,4})-)?([pod]\d{5}):\s+(.+)$", re.MULTILINE
|
|
41
40
|
)
|
|
42
41
|
|
|
43
42
|
# Pattern to find the End footer with hash
|
|
44
43
|
REQ_FOOTER_PATTERN = re.compile(
|
|
45
|
-
r
|
|
46
|
-
re.MULTILINE
|
|
44
|
+
r"^\*End\* \*([^*]+)\* \| \*\*Hash\*\*: ([a-f0-9]{8})$", re.MULTILINE
|
|
47
45
|
)
|
|
48
46
|
|
|
49
47
|
|
|
@@ -52,9 +50,11 @@ REQ_FOOTER_PATTERN = re.compile(
|
|
|
52
50
|
# REQ-tv-d00015-A: Return structured location information
|
|
53
51
|
# =============================================================================
|
|
54
52
|
|
|
53
|
+
|
|
55
54
|
@dataclass
|
|
56
55
|
class ReqLocation:
|
|
57
56
|
"""Location of a requirement in a spec file."""
|
|
57
|
+
|
|
58
58
|
file_path: Path
|
|
59
59
|
line_number: int # 1-based line number of status line
|
|
60
60
|
current_status: str
|
|
@@ -66,6 +66,7 @@ class ReqLocation:
|
|
|
66
66
|
# REQ-tv-d00015-F: Content hash computation and update
|
|
67
67
|
# =============================================================================
|
|
68
68
|
|
|
69
|
+
|
|
69
70
|
def compute_req_hash(content: str) -> str:
|
|
70
71
|
"""
|
|
71
72
|
Compute an 8-character hex hash of requirement content.
|
|
@@ -79,7 +80,7 @@ def compute_req_hash(content: str) -> str:
|
|
|
79
80
|
Returns:
|
|
80
81
|
8-character lowercase hex string
|
|
81
82
|
"""
|
|
82
|
-
return hashlib.sha256(content.encode(
|
|
83
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:8]
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
def _extract_req_content(file_content: str, req_id: str) -> Optional[Tuple[str, int, int]]:
|
|
@@ -96,18 +97,16 @@ def _extract_req_content(file_content: str, req_id: str) -> Optional[Tuple[str,
|
|
|
96
97
|
|
|
97
98
|
# Build pattern for this specific requirement
|
|
98
99
|
# Handle both "tv-d00010" and "HHT-d00001" formats
|
|
99
|
-
if
|
|
100
|
-
parts = normalized_id.split(
|
|
100
|
+
if "-" in normalized_id:
|
|
101
|
+
parts = normalized_id.split("-", 1)
|
|
101
102
|
prefix = parts[0]
|
|
102
103
|
base_id = parts[1]
|
|
103
104
|
header_pattern = re.compile(
|
|
104
|
-
rf
|
|
105
|
-
re.MULTILINE
|
|
105
|
+
rf"^#{{1,6}}\s+REQ-{re.escape(prefix)}-{re.escape(base_id)}:\s+(.+)$", re.MULTILINE
|
|
106
106
|
)
|
|
107
107
|
else:
|
|
108
108
|
header_pattern = re.compile(
|
|
109
|
-
rf
|
|
110
|
-
re.MULTILINE
|
|
109
|
+
rf"^#{{1,6}}\s+REQ-{re.escape(normalized_id)}:\s+(.+)$", re.MULTILINE
|
|
111
110
|
)
|
|
112
111
|
|
|
113
112
|
header_match = header_pattern.search(file_content)
|
|
@@ -120,8 +119,7 @@ def _extract_req_content(file_content: str, req_id: str) -> Optional[Tuple[str,
|
|
|
120
119
|
# Find the footer for this requirement
|
|
121
120
|
# The footer format is: *End* *{title}* | **Hash**: {hash}
|
|
122
121
|
footer_pattern = re.compile(
|
|
123
|
-
rf
|
|
124
|
-
re.MULTILINE
|
|
122
|
+
rf"^\*End\* \*{re.escape(req_title)}\* \| \*\*Hash\*\*: ([a-f0-9]{{8}})$", re.MULTILINE
|
|
125
123
|
)
|
|
126
124
|
|
|
127
125
|
# Search from after the header
|
|
@@ -155,8 +153,8 @@ def update_req_hash(file_path: Path, req_id: str) -> bool:
|
|
|
155
153
|
True if hash was updated, False if requirement not found
|
|
156
154
|
"""
|
|
157
155
|
try:
|
|
158
|
-
content = file_path.read_text(encoding=
|
|
159
|
-
except (
|
|
156
|
+
content = file_path.read_text(encoding="utf-8")
|
|
157
|
+
except (OSError, FileNotFoundError):
|
|
160
158
|
return False
|
|
161
159
|
|
|
162
160
|
result = _extract_req_content(content, req_id)
|
|
@@ -185,6 +183,7 @@ def update_req_hash(file_path: Path, req_id: str) -> bool:
|
|
|
185
183
|
# REQ-tv-d00015-A: find_req_in_file() SHALL locate a requirement
|
|
186
184
|
# =============================================================================
|
|
187
185
|
|
|
186
|
+
|
|
188
187
|
def find_req_in_file(file_path: Path, req_id: str) -> Optional[ReqLocation]:
|
|
189
188
|
"""
|
|
190
189
|
Find a requirement in a spec file and return its position info.
|
|
@@ -201,8 +200,8 @@ def find_req_in_file(file_path: Path, req_id: str) -> Optional[ReqLocation]:
|
|
|
201
200
|
ReqLocation with req info if found, None otherwise
|
|
202
201
|
"""
|
|
203
202
|
try:
|
|
204
|
-
content = file_path.read_text(encoding=
|
|
205
|
-
except (
|
|
203
|
+
content = file_path.read_text(encoding="utf-8")
|
|
204
|
+
except (OSError, FileNotFoundError):
|
|
206
205
|
return None
|
|
207
206
|
|
|
208
207
|
# Normalize req_id (remove REQ- prefix if present)
|
|
@@ -212,18 +211,16 @@ def find_req_in_file(file_path: Path, req_id: str) -> Optional[ReqLocation]:
|
|
|
212
211
|
|
|
213
212
|
# Build pattern for this specific requirement
|
|
214
213
|
# Handle both "tv-d00010" and "HHT-d00001" formats
|
|
215
|
-
if
|
|
216
|
-
parts = normalized_id.split(
|
|
214
|
+
if "-" in normalized_id:
|
|
215
|
+
parts = normalized_id.split("-", 1)
|
|
217
216
|
prefix = parts[0]
|
|
218
217
|
base_id = parts[1]
|
|
219
218
|
header_pattern = re.compile(
|
|
220
|
-
rf
|
|
221
|
-
re.MULTILINE
|
|
219
|
+
rf"^#{{1,6}}\s+REQ-{re.escape(prefix)}-{re.escape(base_id)}:\s+.+$", re.MULTILINE
|
|
222
220
|
)
|
|
223
221
|
else:
|
|
224
222
|
header_pattern = re.compile(
|
|
225
|
-
rf
|
|
226
|
-
re.MULTILINE
|
|
223
|
+
rf"^#{{1,6}}\s+REQ-{re.escape(normalized_id)}:\s+.+$", re.MULTILINE
|
|
227
224
|
)
|
|
228
225
|
|
|
229
226
|
header_match = header_pattern.search(content)
|
|
@@ -246,13 +243,13 @@ def find_req_in_file(file_path: Path, req_id: str) -> Optional[ReqLocation]:
|
|
|
246
243
|
current_status = status_match.group(2)
|
|
247
244
|
|
|
248
245
|
# Calculate 1-based line number
|
|
249
|
-
line_number = content[:status_match.start()].count(
|
|
246
|
+
line_number = content[: status_match.start()].count("\n") + 1
|
|
250
247
|
|
|
251
248
|
return ReqLocation(
|
|
252
249
|
file_path=file_path,
|
|
253
250
|
line_number=line_number,
|
|
254
251
|
current_status=current_status,
|
|
255
|
-
req_id=normalized_id
|
|
252
|
+
req_id=normalized_id,
|
|
256
253
|
)
|
|
257
254
|
|
|
258
255
|
|
|
@@ -273,7 +270,7 @@ def find_req_in_spec_dir(repo_root: Path, req_id: str) -> Optional[ReqLocation]:
|
|
|
273
270
|
spec_dir = repo_root / "spec"
|
|
274
271
|
if spec_dir.exists():
|
|
275
272
|
for spec_file in spec_dir.glob("*.md"):
|
|
276
|
-
if spec_file.name in (
|
|
273
|
+
if spec_file.name in ("INDEX.md", "README.md", "requirements-format.md"):
|
|
277
274
|
continue
|
|
278
275
|
location = find_req_in_file(spec_file, req_id)
|
|
279
276
|
if location:
|
|
@@ -287,7 +284,7 @@ def find_req_in_spec_dir(repo_root: Path, req_id: str) -> Optional[ReqLocation]:
|
|
|
287
284
|
sponsor_spec = sponsor / "spec"
|
|
288
285
|
if sponsor_spec.exists():
|
|
289
286
|
for spec_file in sponsor_spec.glob("*.md"):
|
|
290
|
-
if spec_file.name in (
|
|
287
|
+
if spec_file.name in ("INDEX.md", "README.md", "requirements-format.md"):
|
|
291
288
|
continue
|
|
292
289
|
location = find_req_in_file(spec_file, req_id)
|
|
293
290
|
if location:
|
|
@@ -301,6 +298,7 @@ def find_req_in_spec_dir(repo_root: Path, req_id: str) -> Optional[ReqLocation]:
|
|
|
301
298
|
# REQ-tv-d00015-B: get_req_status() SHALL read and return current status
|
|
302
299
|
# =============================================================================
|
|
303
300
|
|
|
301
|
+
|
|
304
302
|
def get_req_status(repo_root: Path, req_id: str) -> Optional[str]:
|
|
305
303
|
"""
|
|
306
304
|
Get the current status of a requirement.
|
|
@@ -326,6 +324,7 @@ def get_req_status(repo_root: Path, req_id: str) -> Optional[str]:
|
|
|
326
324
|
# REQ-tv-d00015-G: Failed status changes SHALL NOT corrupt the file
|
|
327
325
|
# =============================================================================
|
|
328
326
|
|
|
327
|
+
|
|
329
328
|
def _atomic_write_file(file_path: Path, content: str) -> None:
|
|
330
329
|
"""
|
|
331
330
|
Atomically write content to a file.
|
|
@@ -341,13 +340,9 @@ def _atomic_write_file(file_path: Path, content: str) -> None:
|
|
|
341
340
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
342
341
|
|
|
343
342
|
# 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
|
-
)
|
|
343
|
+
fd, temp_path = tempfile.mkstemp(suffix=".md", prefix=".tmp_", dir=file_path.parent)
|
|
349
344
|
try:
|
|
350
|
-
with os.fdopen(fd,
|
|
345
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
351
346
|
f.write(content)
|
|
352
347
|
# Atomic rename
|
|
353
348
|
os.rename(temp_path, file_path)
|
|
@@ -363,12 +358,8 @@ def _atomic_write_file(file_path: Path, content: str) -> None:
|
|
|
363
358
|
# REQ-tv-d00015-C: change_req_status() SHALL update status atomically
|
|
364
359
|
# =============================================================================
|
|
365
360
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
req_id: str,
|
|
369
|
-
new_status: str,
|
|
370
|
-
user: str
|
|
371
|
-
) -> Tuple[bool, str]:
|
|
361
|
+
|
|
362
|
+
def change_req_status(repo_root: Path, req_id: str, new_status: str, user: str) -> Tuple[bool, str]:
|
|
372
363
|
"""
|
|
373
364
|
Change the status of a requirement in its spec file.
|
|
374
365
|
|
|
@@ -396,7 +387,7 @@ def change_req_status(
|
|
|
396
387
|
"""
|
|
397
388
|
# Validate new_status (REQ-tv-d00015-D)
|
|
398
389
|
if new_status not in VALID_STATUSES:
|
|
399
|
-
valid_list =
|
|
390
|
+
valid_list = ", ".join(sorted(VALID_STATUSES))
|
|
400
391
|
return (False, f"Invalid status '{new_status}'. Valid statuses: {valid_list}")
|
|
401
392
|
|
|
402
393
|
# Find the requirement
|
|
@@ -410,8 +401,8 @@ def change_req_status(
|
|
|
410
401
|
|
|
411
402
|
# Read the file content
|
|
412
403
|
try:
|
|
413
|
-
content = location.file_path.read_text(encoding=
|
|
414
|
-
except
|
|
404
|
+
content = location.file_path.read_text(encoding="utf-8")
|
|
405
|
+
except OSError as e:
|
|
415
406
|
return (False, f"Failed to read spec file: {e}")
|
|
416
407
|
|
|
417
408
|
# Normalize req_id for pattern matching
|
|
@@ -420,18 +411,16 @@ def change_req_status(
|
|
|
420
411
|
normalized_id = normalized_id[4:]
|
|
421
412
|
|
|
422
413
|
# Build pattern for this specific requirement header
|
|
423
|
-
if
|
|
424
|
-
parts = normalized_id.split(
|
|
414
|
+
if "-" in normalized_id:
|
|
415
|
+
parts = normalized_id.split("-", 1)
|
|
425
416
|
prefix = parts[0]
|
|
426
417
|
base_id = parts[1]
|
|
427
418
|
header_pattern = re.compile(
|
|
428
|
-
rf
|
|
429
|
-
re.MULTILINE
|
|
419
|
+
rf"^#{{1,6}}\s+REQ-{re.escape(prefix)}-{re.escape(base_id)}:\s+.+$", re.MULTILINE
|
|
430
420
|
)
|
|
431
421
|
else:
|
|
432
422
|
header_pattern = re.compile(
|
|
433
|
-
rf
|
|
434
|
-
re.MULTILINE
|
|
423
|
+
rf"^#{{1,6}}\s+REQ-{re.escape(normalized_id)}:\s+.+$", re.MULTILINE
|
|
435
424
|
)
|
|
436
425
|
|
|
437
426
|
header_match = header_pattern.search(content)
|
|
@@ -451,16 +440,12 @@ def change_req_status(
|
|
|
451
440
|
new_line = status_match.group(1) + new_status + status_match.group(3)
|
|
452
441
|
|
|
453
442
|
# Replace the status line in content
|
|
454
|
-
new_content = (
|
|
455
|
-
content[:status_match.start()] +
|
|
456
|
-
new_line +
|
|
457
|
-
content[status_match.end():]
|
|
458
|
-
)
|
|
443
|
+
new_content = content[: status_match.start()] + new_line + content[status_match.end() :]
|
|
459
444
|
|
|
460
445
|
# Write atomically (REQ-tv-d00015-G)
|
|
461
446
|
try:
|
|
462
447
|
_atomic_write_file(location.file_path, new_content)
|
|
463
|
-
except
|
|
448
|
+
except OSError as e:
|
|
464
449
|
return (False, f"Failed to write spec file: {e}")
|
|
465
450
|
|
|
466
451
|
# Update the hash (REQ-tv-d00015-F)
|
|
@@ -22,25 +22,25 @@ from pathlib import Path
|
|
|
22
22
|
from typing import Any, Dict, List, Optional
|
|
23
23
|
|
|
24
24
|
from .models import (
|
|
25
|
+
Approval,
|
|
26
|
+
Comment,
|
|
27
|
+
PackagesFile,
|
|
25
28
|
ReviewConfig,
|
|
26
29
|
ReviewFlag,
|
|
27
|
-
|
|
28
|
-
Comment,
|
|
29
|
-
ThreadsFile,
|
|
30
|
+
ReviewPackage,
|
|
30
31
|
StatusFile,
|
|
31
32
|
StatusRequest,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
PackagesFile,
|
|
33
|
+
Thread,
|
|
34
|
+
ThreadsFile,
|
|
35
35
|
parse_iso_datetime,
|
|
36
36
|
)
|
|
37
37
|
|
|
38
|
-
|
|
39
38
|
# =============================================================================
|
|
40
39
|
# Helper Functions
|
|
41
40
|
# REQ-tv-d00011-A: Atomic write operations
|
|
42
41
|
# =============================================================================
|
|
43
42
|
|
|
43
|
+
|
|
44
44
|
def atomic_write_json(path: Path, data: Dict[str, Any]) -> None:
|
|
45
45
|
"""
|
|
46
46
|
Atomically write JSON data to a file.
|
|
@@ -56,13 +56,9 @@ def atomic_write_json(path: Path, data: Dict[str, Any]) -> None:
|
|
|
56
56
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
57
|
|
|
58
58
|
# Write to temp file in same directory (for atomic rename)
|
|
59
|
-
fd, temp_path = tempfile.mkstemp(
|
|
60
|
-
suffix='.json',
|
|
61
|
-
prefix='.tmp_',
|
|
62
|
-
dir=path.parent
|
|
63
|
-
)
|
|
59
|
+
fd, temp_path = tempfile.mkstemp(suffix=".json", prefix=".tmp_", dir=path.parent)
|
|
64
60
|
try:
|
|
65
|
-
with os.fdopen(fd,
|
|
61
|
+
with os.fdopen(fd, "w") as f:
|
|
66
62
|
json.dump(data, f, indent=2)
|
|
67
63
|
# Atomic rename
|
|
68
64
|
os.rename(temp_path, path)
|
|
@@ -87,7 +83,7 @@ def read_json(path: Path) -> Dict[str, Any]:
|
|
|
87
83
|
FileNotFoundError: If file doesn't exist
|
|
88
84
|
json.JSONDecodeError: If file contains invalid JSON
|
|
89
85
|
"""
|
|
90
|
-
with open(path
|
|
86
|
+
with open(path) as f:
|
|
91
87
|
return json.load(f)
|
|
92
88
|
|
|
93
89
|
|
|
@@ -97,6 +93,7 @@ def read_json(path: Path) -> Dict[str, Any]:
|
|
|
97
93
|
# REQ-tv-d00011-I: Requirement ID normalization
|
|
98
94
|
# =============================================================================
|
|
99
95
|
|
|
96
|
+
|
|
100
97
|
def normalize_req_id(req_id: str) -> str:
|
|
101
98
|
"""
|
|
102
99
|
Normalize requirement ID for use in file paths.
|
|
@@ -109,7 +106,7 @@ def normalize_req_id(req_id: str) -> str:
|
|
|
109
106
|
Returns:
|
|
110
107
|
Normalized requirement ID safe for file paths
|
|
111
108
|
"""
|
|
112
|
-
return re.sub(r
|
|
109
|
+
return re.sub(r"[:/]", "_", req_id)
|
|
113
110
|
|
|
114
111
|
|
|
115
112
|
def get_reviews_root(repo_root: Path) -> Path:
|
|
@@ -124,7 +121,7 @@ def get_reviews_root(repo_root: Path) -> Path:
|
|
|
124
121
|
Returns:
|
|
125
122
|
Path to .reviews directory
|
|
126
123
|
"""
|
|
127
|
-
return repo_root /
|
|
124
|
+
return repo_root / ".reviews"
|
|
128
125
|
|
|
129
126
|
|
|
130
127
|
def get_req_dir(repo_root: Path, req_id: str) -> Path:
|
|
@@ -141,7 +138,7 @@ def get_req_dir(repo_root: Path, req_id: str) -> Path:
|
|
|
141
138
|
Path to requirement's review directory
|
|
142
139
|
"""
|
|
143
140
|
normalized = normalize_req_id(req_id)
|
|
144
|
-
return get_reviews_root(repo_root) /
|
|
141
|
+
return get_reviews_root(repo_root) / "reqs" / normalized
|
|
145
142
|
|
|
146
143
|
|
|
147
144
|
def get_threads_path(repo_root: Path, req_id: str) -> Path:
|
|
@@ -157,7 +154,7 @@ def get_threads_path(repo_root: Path, req_id: str) -> Path:
|
|
|
157
154
|
Returns:
|
|
158
155
|
Path to threads.json
|
|
159
156
|
"""
|
|
160
|
-
return get_req_dir(repo_root, req_id) /
|
|
157
|
+
return get_req_dir(repo_root, req_id) / "threads.json"
|
|
161
158
|
|
|
162
159
|
|
|
163
160
|
def get_status_path(repo_root: Path, req_id: str) -> Path:
|
|
@@ -173,7 +170,7 @@ def get_status_path(repo_root: Path, req_id: str) -> Path:
|
|
|
173
170
|
Returns:
|
|
174
171
|
Path to status.json
|
|
175
172
|
"""
|
|
176
|
-
return get_req_dir(repo_root, req_id) /
|
|
173
|
+
return get_req_dir(repo_root, req_id) / "status.json"
|
|
177
174
|
|
|
178
175
|
|
|
179
176
|
def get_review_flag_path(repo_root: Path, req_id: str) -> Path:
|
|
@@ -189,7 +186,7 @@ def get_review_flag_path(repo_root: Path, req_id: str) -> Path:
|
|
|
189
186
|
Returns:
|
|
190
187
|
Path to flag.json
|
|
191
188
|
"""
|
|
192
|
-
return get_req_dir(repo_root, req_id) /
|
|
189
|
+
return get_req_dir(repo_root, req_id) / "flag.json"
|
|
193
190
|
|
|
194
191
|
|
|
195
192
|
def get_config_path(repo_root: Path) -> Path:
|
|
@@ -204,7 +201,7 @@ def get_config_path(repo_root: Path) -> Path:
|
|
|
204
201
|
Returns:
|
|
205
202
|
Path to config.json
|
|
206
203
|
"""
|
|
207
|
-
return get_reviews_root(repo_root) /
|
|
204
|
+
return get_reviews_root(repo_root) / "config.json"
|
|
208
205
|
|
|
209
206
|
|
|
210
207
|
def get_packages_path(repo_root: Path) -> Path:
|
|
@@ -219,7 +216,7 @@ def get_packages_path(repo_root: Path) -> Path:
|
|
|
219
216
|
Returns:
|
|
220
217
|
Path to packages.json
|
|
221
218
|
"""
|
|
222
|
-
return get_reviews_root(repo_root) /
|
|
219
|
+
return get_reviews_root(repo_root) / "packages.json"
|
|
223
220
|
|
|
224
221
|
|
|
225
222
|
# =============================================================================
|
|
@@ -227,6 +224,7 @@ def get_packages_path(repo_root: Path) -> Path:
|
|
|
227
224
|
# REQ-d00096: Review Storage Architecture
|
|
228
225
|
# =============================================================================
|
|
229
226
|
|
|
227
|
+
|
|
230
228
|
def get_index_path(repo_root: Path) -> Path:
|
|
231
229
|
"""
|
|
232
230
|
Get path to index.json file (v2 format).
|
|
@@ -239,7 +237,7 @@ def get_index_path(repo_root: Path) -> Path:
|
|
|
239
237
|
Returns:
|
|
240
238
|
Path to index.json
|
|
241
239
|
"""
|
|
242
|
-
return get_reviews_root(repo_root) /
|
|
240
|
+
return get_reviews_root(repo_root) / "index.json"
|
|
243
241
|
|
|
244
242
|
|
|
245
243
|
def get_package_dir(repo_root: Path, package_id: str) -> Path:
|
|
@@ -255,7 +253,7 @@ def get_package_dir(repo_root: Path, package_id: str) -> Path:
|
|
|
255
253
|
Returns:
|
|
256
254
|
Path to package directory
|
|
257
255
|
"""
|
|
258
|
-
return get_reviews_root(repo_root) /
|
|
256
|
+
return get_reviews_root(repo_root) / "packages" / package_id
|
|
259
257
|
|
|
260
258
|
|
|
261
259
|
def get_package_metadata_path(repo_root: Path, package_id: str) -> Path:
|
|
@@ -271,7 +269,7 @@ def get_package_metadata_path(repo_root: Path, package_id: str) -> Path:
|
|
|
271
269
|
Returns:
|
|
272
270
|
Path to package.json
|
|
273
271
|
"""
|
|
274
|
-
return get_package_dir(repo_root, package_id) /
|
|
272
|
+
return get_package_dir(repo_root, package_id) / "package.json"
|
|
275
273
|
|
|
276
274
|
|
|
277
275
|
def get_package_threads_path(repo_root: Path, package_id: str, req_id: str) -> Path:
|
|
@@ -289,7 +287,7 @@ def get_package_threads_path(repo_root: Path, package_id: str, req_id: str) -> P
|
|
|
289
287
|
Path to threads.json
|
|
290
288
|
"""
|
|
291
289
|
normalized = normalize_req_id(req_id)
|
|
292
|
-
return get_package_dir(repo_root, package_id) /
|
|
290
|
+
return get_package_dir(repo_root, package_id) / "reqs" / normalized / "threads.json"
|
|
293
291
|
|
|
294
292
|
|
|
295
293
|
def get_archive_dir(repo_root: Path) -> Path:
|
|
@@ -304,7 +302,7 @@ def get_archive_dir(repo_root: Path) -> Path:
|
|
|
304
302
|
Returns:
|
|
305
303
|
Path to archive directory
|
|
306
304
|
"""
|
|
307
|
-
return get_reviews_root(repo_root) /
|
|
305
|
+
return get_reviews_root(repo_root) / "archive"
|
|
308
306
|
|
|
309
307
|
|
|
310
308
|
def get_archived_package_dir(repo_root: Path, package_id: str) -> Path:
|
|
@@ -336,14 +334,10 @@ def get_archived_package_metadata_path(repo_root: Path, package_id: str) -> Path
|
|
|
336
334
|
Returns:
|
|
337
335
|
Path to archived package.json
|
|
338
336
|
"""
|
|
339
|
-
return get_archived_package_dir(repo_root, package_id) /
|
|
337
|
+
return get_archived_package_dir(repo_root, package_id) / "package.json"
|
|
340
338
|
|
|
341
339
|
|
|
342
|
-
def get_archived_package_threads_path(
|
|
343
|
-
repo_root: Path,
|
|
344
|
-
package_id: str,
|
|
345
|
-
req_id: str
|
|
346
|
-
) -> Path:
|
|
340
|
+
def get_archived_package_threads_path(repo_root: Path, package_id: str, req_id: str) -> Path:
|
|
347
341
|
"""
|
|
348
342
|
Get path to threads.json file for a requirement within an archived package.
|
|
349
343
|
|
|
@@ -358,7 +352,7 @@ def get_archived_package_threads_path(
|
|
|
358
352
|
Path to archived threads.json
|
|
359
353
|
"""
|
|
360
354
|
normalized = normalize_req_id(req_id)
|
|
361
|
-
return get_archived_package_dir(repo_root, package_id) /
|
|
355
|
+
return get_archived_package_dir(repo_root, package_id) / "reqs" / normalized / "threads.json"
|
|
362
356
|
|
|
363
357
|
|
|
364
358
|
# =============================================================================
|
|
@@ -366,6 +360,7 @@ def get_archived_package_threads_path(
|
|
|
366
360
|
# REQ-tv-d00011-F: Config storage operations
|
|
367
361
|
# =============================================================================
|
|
368
362
|
|
|
363
|
+
|
|
369
364
|
def load_config(repo_root: Path) -> ReviewConfig:
|
|
370
365
|
"""
|
|
371
366
|
Load review system configuration.
|
|
@@ -404,6 +399,7 @@ def save_config(repo_root: Path, config: ReviewConfig) -> None:
|
|
|
404
399
|
# REQ-tv-d00011-D: Review flag storage operations
|
|
405
400
|
# =============================================================================
|
|
406
401
|
|
|
402
|
+
|
|
407
403
|
def load_review_flag(repo_root: Path, req_id: str) -> ReviewFlag:
|
|
408
404
|
"""
|
|
409
405
|
Load review flag for a requirement.
|
|
@@ -444,6 +440,7 @@ def save_review_flag(repo_root: Path, req_id: str, flag: ReviewFlag) -> None:
|
|
|
444
440
|
# REQ-tv-d00011-B: Thread storage operations
|
|
445
441
|
# =============================================================================
|
|
446
442
|
|
|
443
|
+
|
|
447
444
|
def load_threads(repo_root: Path, req_id: str) -> ThreadsFile:
|
|
448
445
|
"""
|
|
449
446
|
Load threads for a requirement.
|
|
@@ -501,11 +498,7 @@ def add_thread(repo_root: Path, req_id: str, thread: Thread) -> Thread:
|
|
|
501
498
|
|
|
502
499
|
|
|
503
500
|
def add_comment_to_thread(
|
|
504
|
-
repo_root: Path,
|
|
505
|
-
req_id: str,
|
|
506
|
-
thread_id: str,
|
|
507
|
-
author: str,
|
|
508
|
-
body: str
|
|
501
|
+
repo_root: Path, req_id: str, thread_id: str, author: str, body: str
|
|
509
502
|
) -> Comment:
|
|
510
503
|
"""
|
|
511
504
|
Add a comment to an existing thread.
|
|
@@ -542,12 +535,7 @@ def add_comment_to_thread(
|
|
|
542
535
|
return comment
|
|
543
536
|
|
|
544
537
|
|
|
545
|
-
def resolve_thread(
|
|
546
|
-
repo_root: Path,
|
|
547
|
-
req_id: str,
|
|
548
|
-
thread_id: str,
|
|
549
|
-
user: str
|
|
550
|
-
) -> bool:
|
|
538
|
+
def resolve_thread(repo_root: Path, req_id: str, thread_id: str, user: str) -> bool:
|
|
551
539
|
"""
|
|
552
540
|
Mark a thread as resolved.
|
|
553
541
|
|
|
@@ -603,6 +591,7 @@ def unresolve_thread(repo_root: Path, req_id: str, thread_id: str) -> bool:
|
|
|
603
591
|
# REQ-tv-d00011-C: Status request storage operations
|
|
604
592
|
# =============================================================================
|
|
605
593
|
|
|
594
|
+
|
|
606
595
|
def load_status_requests(repo_root: Path, req_id: str) -> StatusFile:
|
|
607
596
|
"""
|
|
608
597
|
Load status requests for a requirement.
|
|
@@ -639,11 +628,7 @@ def save_status_requests(repo_root: Path, req_id: str, status_file: StatusFile)
|
|
|
639
628
|
atomic_write_json(status_path, status_file.to_dict())
|
|
640
629
|
|
|
641
630
|
|
|
642
|
-
def create_status_request(
|
|
643
|
-
repo_root: Path,
|
|
644
|
-
req_id: str,
|
|
645
|
-
request: StatusRequest
|
|
646
|
-
) -> StatusRequest:
|
|
631
|
+
def create_status_request(repo_root: Path, req_id: str, request: StatusRequest) -> StatusRequest:
|
|
647
632
|
"""
|
|
648
633
|
Create a new status change request.
|
|
649
634
|
|
|
@@ -669,7 +654,7 @@ def add_approval(
|
|
|
669
654
|
request_id: str,
|
|
670
655
|
user: str,
|
|
671
656
|
decision: str,
|
|
672
|
-
comment: Optional[str] = None
|
|
657
|
+
comment: Optional[str] = None,
|
|
673
658
|
) -> Approval:
|
|
674
659
|
"""
|
|
675
660
|
Add an approval to a status request.
|
|
@@ -740,6 +725,7 @@ def mark_request_applied(repo_root: Path, req_id: str, request_id: str) -> bool:
|
|
|
740
725
|
# REQ-tv-d00011-E: Package storage operations
|
|
741
726
|
# =============================================================================
|
|
742
727
|
|
|
728
|
+
|
|
743
729
|
def load_packages(repo_root: Path) -> PackagesFile:
|
|
744
730
|
"""
|
|
745
731
|
Load packages file.
|
|
@@ -907,6 +893,7 @@ def remove_req_from_package(repo_root: Path, package_id: str, req_id: str) -> bo
|
|
|
907
893
|
# REQ-tv-d00011-J: Deduplication and timestamp-based conflict resolution
|
|
908
894
|
# =============================================================================
|
|
909
895
|
|
|
896
|
+
|
|
910
897
|
def merge_threads(local: ThreadsFile, remote: ThreadsFile) -> ThreadsFile:
|
|
911
898
|
"""
|
|
912
899
|
Merge thread files from local and remote.
|
|
@@ -985,7 +972,7 @@ def _merge_single_thread(local: Thread, remote: Thread) -> Thread:
|
|
|
985
972
|
resolved=resolved,
|
|
986
973
|
resolvedBy=resolved_by,
|
|
987
974
|
resolvedAt=resolved_at,
|
|
988
|
-
comments=merged_comments
|
|
975
|
+
comments=merged_comments,
|
|
989
976
|
)
|
|
990
977
|
|
|
991
978
|
|
|
@@ -1073,7 +1060,7 @@ def _merge_single_request(local: StatusRequest, remote: StatusRequest) -> Status
|
|
|
1073
1060
|
justification=local.justification,
|
|
1074
1061
|
approvals=merged_approvals,
|
|
1075
1062
|
requiredApprovers=local.requiredApprovers,
|
|
1076
|
-
state=local.state # Will be recalculated
|
|
1063
|
+
state=local.state, # Will be recalculated
|
|
1077
1064
|
)
|
|
1078
1065
|
|
|
1079
1066
|
# Recalculate state based on merged approvals
|
|
@@ -1125,7 +1112,7 @@ def merge_review_flags(local: ReviewFlag, remote: ReviewFlag) -> ReviewFlag:
|
|
|
1125
1112
|
flaggedBy=remote.flaggedBy,
|
|
1126
1113
|
flaggedAt=remote.flaggedAt,
|
|
1127
1114
|
reason=remote.reason,
|
|
1128
|
-
scope=merged_scope
|
|
1115
|
+
scope=merged_scope,
|
|
1129
1116
|
)
|
|
1130
1117
|
else:
|
|
1131
1118
|
# Local is newer
|
|
@@ -1134,7 +1121,7 @@ def merge_review_flags(local: ReviewFlag, remote: ReviewFlag) -> ReviewFlag:
|
|
|
1134
1121
|
flaggedBy=local.flaggedBy,
|
|
1135
1122
|
flaggedAt=local.flaggedAt,
|
|
1136
1123
|
reason=local.reason,
|
|
1137
|
-
scope=merged_scope
|
|
1124
|
+
scope=merged_scope,
|
|
1138
1125
|
)
|
|
1139
1126
|
|
|
1140
1127
|
|
|
@@ -1143,12 +1130,8 @@ def merge_review_flags(local: ReviewFlag, remote: ReviewFlag) -> ReviewFlag:
|
|
|
1143
1130
|
# REQ-d00097: Review Package Archival
|
|
1144
1131
|
# =============================================================================
|
|
1145
1132
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
package_id: str,
|
|
1149
|
-
reason: str,
|
|
1150
|
-
user: str
|
|
1151
|
-
) -> bool:
|
|
1133
|
+
|
|
1134
|
+
def archive_package(repo_root: Path, package_id: str, reason: str, user: str) -> bool:
|
|
1152
1135
|
"""
|
|
1153
1136
|
Archive a package by moving it to the archive directory.
|
|
1154
1137
|
|
|
@@ -1169,9 +1152,9 @@ def archive_package(
|
|
|
1169
1152
|
ValueError: If reason is not valid
|
|
1170
1153
|
"""
|
|
1171
1154
|
from .models import (
|
|
1172
|
-
ARCHIVE_REASON_RESOLVED,
|
|
1173
1155
|
ARCHIVE_REASON_DELETED,
|
|
1174
1156
|
ARCHIVE_REASON_MANUAL,
|
|
1157
|
+
ARCHIVE_REASON_RESOLVED,
|
|
1175
1158
|
)
|
|
1176
1159
|
|
|
1177
1160
|
valid_reasons = {ARCHIVE_REASON_RESOLVED, ARCHIVE_REASON_DELETED, ARCHIVE_REASON_MANUAL}
|
|
@@ -1239,7 +1222,7 @@ def list_archived_packages(repo_root: Path) -> List[ReviewPackage]:
|
|
|
1239
1222
|
packages = []
|
|
1240
1223
|
for pkg_dir in archive_root.iterdir():
|
|
1241
1224
|
if pkg_dir.is_dir():
|
|
1242
|
-
metadata_path = pkg_dir /
|
|
1225
|
+
metadata_path = pkg_dir / "package.json"
|
|
1243
1226
|
if metadata_path.exists():
|
|
1244
1227
|
try:
|
|
1245
1228
|
data = read_json(metadata_path)
|
|
@@ -1250,10 +1233,7 @@ def list_archived_packages(repo_root: Path) -> List[ReviewPackage]:
|
|
|
1250
1233
|
continue
|
|
1251
1234
|
|
|
1252
1235
|
# Sort by archive date (most recent first)
|
|
1253
|
-
packages.sort(
|
|
1254
|
-
key=lambda p: p.archivedAt or '',
|
|
1255
|
-
reverse=True
|
|
1256
|
-
)
|
|
1236
|
+
packages.sort(key=lambda p: p.archivedAt or "", reverse=True)
|
|
1257
1237
|
|
|
1258
1238
|
return packages
|
|
1259
1239
|
|
|
@@ -1282,11 +1262,7 @@ def get_archived_package(repo_root: Path, package_id: str) -> Optional[ReviewPac
|
|
|
1282
1262
|
return None
|
|
1283
1263
|
|
|
1284
1264
|
|
|
1285
|
-
def load_archived_threads(
|
|
1286
|
-
repo_root: Path,
|
|
1287
|
-
package_id: str,
|
|
1288
|
-
req_id: str
|
|
1289
|
-
) -> Optional[ThreadsFile]:
|
|
1265
|
+
def load_archived_threads(repo_root: Path, package_id: str, req_id: str) -> Optional[ThreadsFile]:
|
|
1290
1266
|
"""
|
|
1291
1267
|
Load threads for a requirement from an archived package.
|
|
1292
1268
|
|