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.
Files changed (73) hide show
  1. elspais/cli.py +99 -1
  2. elspais/commands/hash_cmd.py +72 -26
  3. elspais/commands/reformat_cmd.py +458 -0
  4. elspais/commands/trace.py +157 -3
  5. elspais/commands/validate.py +44 -16
  6. elspais/core/models.py +2 -0
  7. elspais/core/parser.py +68 -24
  8. elspais/reformat/__init__.py +50 -0
  9. elspais/reformat/detector.py +119 -0
  10. elspais/reformat/hierarchy.py +246 -0
  11. elspais/reformat/line_breaks.py +220 -0
  12. elspais/reformat/prompts.py +123 -0
  13. elspais/reformat/transformer.py +264 -0
  14. elspais/sponsors/__init__.py +432 -0
  15. elspais/trace_view/__init__.py +54 -0
  16. elspais/trace_view/coverage.py +183 -0
  17. elspais/trace_view/generators/__init__.py +12 -0
  18. elspais/trace_view/generators/base.py +329 -0
  19. elspais/trace_view/generators/csv.py +122 -0
  20. elspais/trace_view/generators/markdown.py +175 -0
  21. elspais/trace_view/html/__init__.py +31 -0
  22. elspais/trace_view/html/generator.py +1006 -0
  23. elspais/trace_view/html/templates/base.html +283 -0
  24. elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
  25. elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
  26. elspais/trace_view/html/templates/components/legend_modal.html +69 -0
  27. elspais/trace_view/html/templates/components/review_panel.html +118 -0
  28. elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
  29. elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
  30. elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
  31. elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
  32. elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
  33. elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
  34. elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
  35. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
  36. elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
  37. elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
  38. elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
  39. elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
  40. elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
  41. elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
  42. elspais/trace_view/html/templates/partials/scripts.js +1741 -0
  43. elspais/trace_view/html/templates/partials/styles.css +1756 -0
  44. elspais/trace_view/models.py +353 -0
  45. elspais/trace_view/review/__init__.py +60 -0
  46. elspais/trace_view/review/branches.py +1149 -0
  47. elspais/trace_view/review/models.py +1205 -0
  48. elspais/trace_view/review/position.py +609 -0
  49. elspais/trace_view/review/server.py +1056 -0
  50. elspais/trace_view/review/status.py +470 -0
  51. elspais/trace_view/review/storage.py +1367 -0
  52. elspais/trace_view/scanning.py +213 -0
  53. elspais/trace_view/specs/README.md +84 -0
  54. elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
  55. elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
  56. elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
  57. elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
  58. elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
  59. elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
  60. elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
  61. elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
  62. elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
  63. elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
  64. elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
  65. elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
  66. elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
  67. elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
  68. {elspais-0.9.3.dist-info → elspais-0.11.0.dist-info}/METADATA +33 -18
  69. elspais-0.11.0.dist-info/RECORD +101 -0
  70. elspais-0.9.3.dist-info/RECORD +0 -40
  71. {elspais-0.9.3.dist-info → elspais-0.11.0.dist-info}/WHEEL +0 -0
  72. {elspais-0.9.3.dist-info → elspais-0.11.0.dist-info}/entry_points.txt +0 -0
  73. {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}'")