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,1205 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Review Data Models for trace_view
4
+
5
+ Data classes for the review system including:
6
+ - ReviewFlag: Mark REQs for review
7
+ - CommentPosition: Position-aware comment anchoring
8
+ - Comment: Individual comments
9
+ - Thread: Comment threads with position and package ownership
10
+ - StatusRequest: Status change requests with approvals
11
+ - Approval: Individual approvals
12
+ - ReviewSession: Review session metadata
13
+ - ReviewConfig: System configuration
14
+ - ReviewPackage: Named collections of REQs under review with audit trail
15
+
16
+ IMPLEMENTS REQUIREMENTS:
17
+ REQ-tv-d00010: Review Data Models
18
+ REQ-d00094: TraceView Review System Core
19
+ REQ-d00095: Review Package Management
20
+ """
21
+
22
+ import re
23
+ import uuid
24
+ from dataclasses import dataclass, field, asdict
25
+ from datetime import datetime, timezone
26
+ from enum import Enum
27
+ from typing import Any, Dict, List, Optional, Tuple
28
+
29
+
30
+ # =============================================================================
31
+ # Enums and Constants
32
+ # REQ-tv-d00010-B: String enums for JSON compatibility
33
+ # =============================================================================
34
+
35
+ class PositionType(str, Enum):
36
+ """Type of comment position anchor"""
37
+ LINE = "line"
38
+ BLOCK = "block"
39
+ WORD = "word"
40
+ GENERAL = "general"
41
+
42
+
43
+ class RequestState(str, Enum):
44
+ """State of a status change request"""
45
+ PENDING = "pending"
46
+ APPROVED = "approved"
47
+ REJECTED = "rejected"
48
+ APPLIED = "applied"
49
+
50
+
51
+ class ApprovalDecision(str, Enum):
52
+ """Approval decision type"""
53
+ APPROVE = "approve"
54
+ REJECT = "reject"
55
+
56
+
57
+ # Valid REQ status values
58
+ VALID_REQ_STATUSES = {"Draft", "Active", "Deprecated"}
59
+
60
+ # Default approval rules for status transitions
61
+ DEFAULT_APPROVAL_RULES: Dict[str, List[str]] = {
62
+ "Draft->Active": ["product_owner", "tech_lead"],
63
+ "Active->Deprecated": ["product_owner"],
64
+ "Draft->Deprecated": ["product_owner"]
65
+ }
66
+
67
+
68
+ # =============================================================================
69
+ # Utility Functions
70
+ # =============================================================================
71
+
72
+ def generate_uuid() -> str:
73
+ """Generate a new UUID string"""
74
+ return str(uuid.uuid4())
75
+
76
+
77
+ def now_iso() -> str:
78
+ """
79
+ Get current UTC timestamp in ISO 8601 format.
80
+
81
+ REQ-tv-d00010-J: All timestamps SHALL be UTC in ISO 8601 format.
82
+ """
83
+ return datetime.now(timezone.utc).isoformat()
84
+
85
+
86
+ def parse_iso_datetime(iso_str: str) -> datetime:
87
+ """Parse ISO 8601 datetime string to datetime object"""
88
+ # Handle both with and without timezone, and Z suffix
89
+ if iso_str.endswith('Z'):
90
+ iso_str = iso_str[:-1] + '+00:00'
91
+ return datetime.fromisoformat(iso_str)
92
+
93
+
94
+ def validate_req_id(req_id: str) -> bool:
95
+ """
96
+ Validate requirement ID format.
97
+
98
+ Valid formats:
99
+ - d00001, p00042, o00003 (core REQs)
100
+ - CAL-d00001 (sponsor-specific REQs)
101
+
102
+ Does NOT accept REQ- prefix.
103
+ """
104
+ if not req_id:
105
+ return False
106
+ # Negative lookahead to reject REQ- prefix; only sponsor prefixes allowed
107
+ pattern = r'^(?!REQ-)(?:[A-Z]{2,4}-)?[pod]\d{5}$'
108
+ return bool(re.match(pattern, req_id))
109
+
110
+
111
+ def validate_hash(hash_value: str) -> bool:
112
+ """Validate 8-character hex hash format"""
113
+ if not hash_value:
114
+ return False
115
+ return bool(re.match(r'^[a-fA-F0-9]{8}$', hash_value))
116
+
117
+
118
+ # =============================================================================
119
+ # Data Classes
120
+ # REQ-tv-d00010-A: All types implemented as dataclasses
121
+ # REQ-tv-d00010-C: Each dataclass implements to_dict()
122
+ # REQ-tv-d00010-D: Each dataclass implements from_dict()
123
+ # REQ-tv-d00010-E: Each dataclass implements validate()
124
+ # =============================================================================
125
+
126
+ @dataclass
127
+ class CommentPosition:
128
+ """
129
+ Position anchor for a comment within a requirement.
130
+
131
+ REQ-tv-d00010-H: Supports four anchor types: LINE, BLOCK, WORD, GENERAL.
132
+
133
+ Supports multiple anchor types:
134
+ - "line": Specific line number
135
+ - "block": Range of lines (e.g., entire section)
136
+ - "word": Specific keyword occurrence
137
+ - "general": No specific position (applies to whole REQ)
138
+
139
+ The hashWhenCreated allows detection of content drift.
140
+ """
141
+ type: str # PositionType value as string for JSON compatibility
142
+ hashWhenCreated: str # 8-char REQ hash when comment was created
143
+ lineNumber: Optional[int] = None
144
+ lineRange: Optional[Tuple[int, int]] = None
145
+ keyword: Optional[str] = None
146
+ keywordOccurrence: Optional[int] = None # 1-based occurrence index
147
+ fallbackContext: Optional[str] = None # Snippet for finding position on hash mismatch
148
+
149
+ def __post_init__(self):
150
+ """Validate position type matches provided fields"""
151
+ if isinstance(self.type, PositionType):
152
+ self.type = self.type.value
153
+
154
+ @classmethod
155
+ def create_line(cls, hash_value: str, line_number: int,
156
+ context: Optional[str] = None) -> 'CommentPosition':
157
+ """Factory for line-anchored position"""
158
+ return cls(
159
+ type=PositionType.LINE.value,
160
+ hashWhenCreated=hash_value,
161
+ lineNumber=line_number,
162
+ fallbackContext=context
163
+ )
164
+
165
+ @classmethod
166
+ def create_block(cls, hash_value: str, start_line: int, end_line: int,
167
+ context: Optional[str] = None) -> 'CommentPosition':
168
+ """Factory for block-anchored position"""
169
+ return cls(
170
+ type=PositionType.BLOCK.value,
171
+ hashWhenCreated=hash_value,
172
+ lineRange=(start_line, end_line),
173
+ fallbackContext=context
174
+ )
175
+
176
+ @classmethod
177
+ def create_word(cls, hash_value: str, keyword: str, occurrence: int = 1,
178
+ context: Optional[str] = None) -> 'CommentPosition':
179
+ """Factory for word-anchored position"""
180
+ return cls(
181
+ type=PositionType.WORD.value,
182
+ hashWhenCreated=hash_value,
183
+ keyword=keyword,
184
+ keywordOccurrence=occurrence,
185
+ fallbackContext=context
186
+ )
187
+
188
+ @classmethod
189
+ def create_general(cls, hash_value: str) -> 'CommentPosition':
190
+ """Factory for general (whole REQ) position"""
191
+ return cls(
192
+ type=PositionType.GENERAL.value,
193
+ hashWhenCreated=hash_value
194
+ )
195
+
196
+ def validate(self) -> Tuple[bool, List[str]]:
197
+ """
198
+ Validate position fields based on type.
199
+
200
+ REQ-tv-d00010-E: Returns (is_valid, list_of_error_messages)
201
+ """
202
+ errors = []
203
+
204
+ if self.type not in [pt.value for pt in PositionType]:
205
+ errors.append(f"Invalid position type: {self.type}")
206
+ return False, errors
207
+
208
+ if not validate_hash(self.hashWhenCreated):
209
+ errors.append(f"Invalid hash format: {self.hashWhenCreated}")
210
+
211
+ if self.type == PositionType.LINE.value:
212
+ if self.lineNumber is None:
213
+ errors.append("lineNumber required for 'line' type")
214
+ elif self.lineNumber < 1:
215
+ errors.append("lineNumber must be positive")
216
+
217
+ elif self.type == PositionType.BLOCK.value:
218
+ if self.lineRange is None:
219
+ errors.append("lineRange required for 'block' type")
220
+ elif len(self.lineRange) != 2:
221
+ errors.append("lineRange must be tuple of (start, end)")
222
+ elif self.lineRange[0] < 1 or self.lineRange[1] < self.lineRange[0]:
223
+ errors.append("Invalid lineRange: start must be >= 1 and end >= start")
224
+
225
+ elif self.type == PositionType.WORD.value:
226
+ if not self.keyword:
227
+ errors.append("keyword required for 'word' type")
228
+ if self.keywordOccurrence is not None and self.keywordOccurrence < 1:
229
+ errors.append("keywordOccurrence must be positive")
230
+
231
+ return len(errors) == 0, errors
232
+
233
+ def to_dict(self) -> Dict[str, Any]:
234
+ """
235
+ Convert to JSON-serializable dictionary.
236
+
237
+ REQ-tv-d00010-C: Returns JSON-serializable dict.
238
+ """
239
+ result: Dict[str, Any] = {
240
+ 'type': self.type,
241
+ 'hashWhenCreated': self.hashWhenCreated
242
+ }
243
+ if self.lineNumber is not None:
244
+ result['lineNumber'] = self.lineNumber
245
+ if self.lineRange is not None:
246
+ result['lineRange'] = list(self.lineRange) # Tuple to list for JSON
247
+ if self.keyword is not None:
248
+ result['keyword'] = self.keyword
249
+ if self.keywordOccurrence is not None:
250
+ result['keywordOccurrence'] = self.keywordOccurrence
251
+ if self.fallbackContext is not None:
252
+ result['fallbackContext'] = self.fallbackContext
253
+ return result
254
+
255
+ @classmethod
256
+ def from_dict(cls, data: Dict[str, Any]) -> 'CommentPosition':
257
+ """
258
+ Create from dictionary (JSON deserialization).
259
+
260
+ REQ-tv-d00010-D: Deserializes from dictionaries.
261
+ """
262
+ line_range = data.get('lineRange')
263
+ if line_range is not None:
264
+ line_range = tuple(line_range) # List to tuple
265
+ return cls(
266
+ type=data['type'],
267
+ hashWhenCreated=data['hashWhenCreated'],
268
+ lineNumber=data.get('lineNumber'),
269
+ lineRange=line_range,
270
+ keyword=data.get('keyword'),
271
+ keywordOccurrence=data.get('keywordOccurrence'),
272
+ fallbackContext=data.get('fallbackContext')
273
+ )
274
+
275
+
276
+ @dataclass
277
+ class Comment:
278
+ """
279
+ Single comment in a thread.
280
+
281
+ Comments are immutable once created - edits create new comments
282
+ with references to the original.
283
+ """
284
+ id: str # UUID
285
+ author: str # Username
286
+ timestamp: str # ISO 8601 datetime
287
+ body: str # Markdown content
288
+
289
+ @classmethod
290
+ def create(cls, author: str, body: str) -> 'Comment':
291
+ """
292
+ Factory for creating new comment with auto-generated fields.
293
+
294
+ REQ-tv-d00010-F: Auto-generates IDs and timestamps.
295
+ REQ-tv-d00010-J: Uses UTC timestamps.
296
+ """
297
+ return cls(
298
+ id=generate_uuid(),
299
+ author=author,
300
+ timestamp=now_iso(),
301
+ body=body
302
+ )
303
+
304
+ def validate(self) -> Tuple[bool, List[str]]:
305
+ """Validate comment fields"""
306
+ errors = []
307
+
308
+ if not self.id:
309
+ errors.append("Comment id is required")
310
+ if not self.author:
311
+ errors.append("Comment author is required")
312
+ if not self.timestamp:
313
+ errors.append("Comment timestamp is required")
314
+ if not self.body or not self.body.strip():
315
+ errors.append("Comment body cannot be empty")
316
+
317
+ # Validate timestamp format
318
+ if self.timestamp:
319
+ try:
320
+ parse_iso_datetime(self.timestamp)
321
+ except ValueError:
322
+ errors.append(f"Invalid timestamp format: {self.timestamp}")
323
+
324
+ return len(errors) == 0, errors
325
+
326
+ def to_dict(self) -> Dict[str, Any]:
327
+ """Convert to JSON-serializable dictionary"""
328
+ return asdict(self)
329
+
330
+ @classmethod
331
+ def from_dict(cls, data: Dict[str, Any]) -> 'Comment':
332
+ """Create from dictionary"""
333
+ return cls(
334
+ id=data['id'],
335
+ author=data['author'],
336
+ timestamp=data['timestamp'],
337
+ body=data['body']
338
+ )
339
+
340
+
341
+ @dataclass
342
+ class Thread:
343
+ """
344
+ Comment thread with position anchor.
345
+
346
+ A thread is a collection of comments about a specific location
347
+ in a requirement. Threads can be resolved.
348
+
349
+ REQ-d00094-A: Thread model with packageId for package ownership.
350
+ """
351
+ threadId: str # UUID
352
+ reqId: str # Requirement ID (e.g., "d00027")
353
+ createdBy: str # Username who started thread
354
+ createdAt: str # ISO 8601 datetime
355
+ position: CommentPosition
356
+ resolved: bool = False
357
+ resolvedBy: Optional[str] = None
358
+ resolvedAt: Optional[str] = None
359
+ comments: List[Comment] = field(default_factory=list)
360
+ # REQ-d00094-A: Package ownership (optional for backward compatibility)
361
+ packageId: Optional[str] = None
362
+
363
+ @classmethod
364
+ def create(cls, req_id: str, creator: str, position: CommentPosition,
365
+ initial_comment: Optional[str] = None,
366
+ package_id: Optional[str] = None) -> 'Thread':
367
+ """
368
+ Factory for creating new thread.
369
+
370
+ REQ-tv-d00010-F: Auto-generates IDs and timestamps.
371
+ REQ-d00094-A: Supports package ownership.
372
+
373
+ Args:
374
+ req_id: Requirement ID this thread is about
375
+ creator: Username of thread creator
376
+ position: Position anchor for the thread
377
+ initial_comment: Optional first comment body
378
+ package_id: Optional package ID that owns this thread
379
+ """
380
+ thread = cls(
381
+ threadId=generate_uuid(),
382
+ reqId=req_id,
383
+ createdBy=creator,
384
+ createdAt=now_iso(),
385
+ position=position,
386
+ packageId=package_id
387
+ )
388
+ if initial_comment:
389
+ thread.add_comment(creator, initial_comment)
390
+ return thread
391
+
392
+ def add_comment(self, author: str, body: str) -> Comment:
393
+ """Add a new comment to the thread"""
394
+ comment = Comment.create(author, body)
395
+ self.comments.append(comment)
396
+ return comment
397
+
398
+ def resolve(self, user: str) -> None:
399
+ """Mark thread as resolved"""
400
+ self.resolved = True
401
+ self.resolvedBy = user
402
+ self.resolvedAt = now_iso()
403
+
404
+ def unresolve(self) -> None:
405
+ """Mark thread as unresolved"""
406
+ self.resolved = False
407
+ self.resolvedBy = None
408
+ self.resolvedAt = None
409
+
410
+ def validate(self) -> Tuple[bool, List[str]]:
411
+ """Validate thread and all comments"""
412
+ errors = []
413
+
414
+ if not self.threadId:
415
+ errors.append("Thread threadId is required")
416
+ if not validate_req_id(self.reqId):
417
+ errors.append(f"Invalid requirement ID: {self.reqId}")
418
+ if not self.createdBy:
419
+ errors.append("Thread createdBy is required")
420
+
421
+ # Validate position
422
+ pos_valid, pos_errors = self.position.validate()
423
+ errors.extend([f"Position: {e}" for e in pos_errors])
424
+
425
+ # Validate resolution state
426
+ if self.resolved:
427
+ if not self.resolvedBy:
428
+ errors.append("Resolved thread must have resolvedBy")
429
+ if not self.resolvedAt:
430
+ errors.append("Resolved thread must have resolvedAt")
431
+
432
+ # Validate comments
433
+ for i, comment in enumerate(self.comments):
434
+ comment_valid, comment_errors = comment.validate()
435
+ errors.extend([f"Comment[{i}]: {e}" for e in comment_errors])
436
+
437
+ return len(errors) == 0, errors
438
+
439
+ def to_dict(self) -> Dict[str, Any]:
440
+ """Convert to JSON-serializable dictionary"""
441
+ result = {
442
+ 'threadId': self.threadId,
443
+ 'reqId': self.reqId,
444
+ 'createdBy': self.createdBy,
445
+ 'createdAt': self.createdAt,
446
+ 'position': self.position.to_dict(),
447
+ 'resolved': self.resolved,
448
+ 'resolvedBy': self.resolvedBy,
449
+ 'resolvedAt': self.resolvedAt,
450
+ 'comments': [c.to_dict() for c in self.comments]
451
+ }
452
+ # REQ-d00094-A: Include packageId if set
453
+ if self.packageId is not None:
454
+ result['packageId'] = self.packageId
455
+ return result
456
+
457
+ @classmethod
458
+ def from_dict(cls, data: Dict[str, Any]) -> 'Thread':
459
+ """Create from dictionary"""
460
+ return cls(
461
+ threadId=data['threadId'],
462
+ reqId=data['reqId'],
463
+ createdBy=data['createdBy'],
464
+ createdAt=data['createdAt'],
465
+ position=CommentPosition.from_dict(data['position']),
466
+ resolved=data.get('resolved', False),
467
+ resolvedBy=data.get('resolvedBy'),
468
+ resolvedAt=data.get('resolvedAt'),
469
+ comments=[Comment.from_dict(c) for c in data.get('comments', [])],
470
+ # REQ-d00094-A: Package ownership (optional for backward compatibility)
471
+ packageId=data.get('packageId')
472
+ )
473
+
474
+
475
+ @dataclass
476
+ class ReviewFlag:
477
+ """
478
+ Marks a requirement for review.
479
+
480
+ When a requirement is flagged, it signals that reviewers in the
481
+ specified scope should examine it.
482
+ """
483
+ flaggedForReview: bool
484
+ flaggedBy: str # Username
485
+ flaggedAt: str # ISO 8601 datetime
486
+ reason: str
487
+ scope: List[str] # List of roles/users who should review
488
+
489
+ @classmethod
490
+ def create(cls, user: str, reason: str, scope: List[str]) -> 'ReviewFlag':
491
+ """Factory for creating new review flag"""
492
+ return cls(
493
+ flaggedForReview=True,
494
+ flaggedBy=user,
495
+ flaggedAt=now_iso(),
496
+ reason=reason,
497
+ scope=scope
498
+ )
499
+
500
+ @classmethod
501
+ def cleared(cls) -> 'ReviewFlag':
502
+ """Factory for an unflagged state"""
503
+ return cls(
504
+ flaggedForReview=False,
505
+ flaggedBy='',
506
+ flaggedAt='',
507
+ reason='',
508
+ scope=[]
509
+ )
510
+
511
+ def clear(self) -> None:
512
+ """Clear the review flag"""
513
+ self.flaggedForReview = False
514
+ self.flaggedBy = ''
515
+ self.flaggedAt = ''
516
+ self.reason = ''
517
+ self.scope = []
518
+
519
+ def validate(self) -> Tuple[bool, List[str]]:
520
+ """Validate flag state"""
521
+ errors = []
522
+
523
+ if self.flaggedForReview:
524
+ if not self.flaggedBy:
525
+ errors.append("Flagged review must have flaggedBy")
526
+ if not self.flaggedAt:
527
+ errors.append("Flagged review must have flaggedAt")
528
+ if not self.reason:
529
+ errors.append("Flagged review must have reason")
530
+ if not self.scope:
531
+ errors.append("Flagged review must have non-empty scope")
532
+
533
+ return len(errors) == 0, errors
534
+
535
+ def to_dict(self) -> Dict[str, Any]:
536
+ """Convert to JSON-serializable dictionary"""
537
+ return asdict(self)
538
+
539
+ @classmethod
540
+ def from_dict(cls, data: Dict[str, Any]) -> 'ReviewFlag':
541
+ """Create from dictionary"""
542
+ return cls(
543
+ flaggedForReview=data['flaggedForReview'],
544
+ flaggedBy=data.get('flaggedBy', ''),
545
+ flaggedAt=data.get('flaggedAt', ''),
546
+ reason=data.get('reason', ''),
547
+ scope=data.get('scope', [])
548
+ )
549
+
550
+
551
+ @dataclass
552
+ class Approval:
553
+ """
554
+ Single approval on a status change request.
555
+ """
556
+ user: str # Username
557
+ decision: str # ApprovalDecision value
558
+ at: str # ISO 8601 datetime
559
+ comment: Optional[str] = None
560
+
561
+ @classmethod
562
+ def create(cls, user: str, decision: str,
563
+ comment: Optional[str] = None) -> 'Approval':
564
+ """Factory for creating new approval"""
565
+ return cls(
566
+ user=user,
567
+ decision=decision,
568
+ at=now_iso(),
569
+ comment=comment
570
+ )
571
+
572
+ def validate(self) -> Tuple[bool, List[str]]:
573
+ """Validate approval"""
574
+ errors = []
575
+
576
+ if not self.user:
577
+ errors.append("Approval user is required")
578
+ if self.decision not in [d.value for d in ApprovalDecision]:
579
+ errors.append(f"Invalid decision: {self.decision}")
580
+ if not self.at:
581
+ errors.append("Approval timestamp (at) is required")
582
+
583
+ return len(errors) == 0, errors
584
+
585
+ def to_dict(self) -> Dict[str, Any]:
586
+ """Convert to JSON-serializable dictionary"""
587
+ result: Dict[str, Any] = {
588
+ 'user': self.user,
589
+ 'decision': self.decision,
590
+ 'at': self.at
591
+ }
592
+ if self.comment is not None:
593
+ result['comment'] = self.comment
594
+ return result
595
+
596
+ @classmethod
597
+ def from_dict(cls, data: Dict[str, Any]) -> 'Approval':
598
+ """Create from dictionary"""
599
+ return cls(
600
+ user=data['user'],
601
+ decision=data['decision'],
602
+ at=data['at'],
603
+ comment=data.get('comment')
604
+ )
605
+
606
+
607
+ @dataclass
608
+ class StatusRequest:
609
+ """
610
+ Request to change a requirement's status.
611
+
612
+ REQ-tv-d00010-I: Automatically calculates state based on approval votes.
613
+
614
+ Status changes require approvals from designated approvers.
615
+ Valid transitions:
616
+ - Draft -> Active (requires product_owner, tech_lead)
617
+ - Active -> Deprecated (requires product_owner)
618
+ - Draft -> Deprecated (requires product_owner)
619
+ """
620
+ requestId: str # UUID
621
+ reqId: str # Requirement ID
622
+ type: str # Always "status_change"
623
+ fromStatus: str
624
+ toStatus: str
625
+ requestedBy: str # Username
626
+ requestedAt: str # ISO 8601 datetime
627
+ justification: str
628
+ approvals: List[Approval]
629
+ requiredApprovers: List[str] # Roles/users required to approve
630
+ state: str # RequestState value
631
+
632
+ @classmethod
633
+ def create(cls, req_id: str, from_status: str, to_status: str,
634
+ requested_by: str, justification: str,
635
+ required_approvers: Optional[List[str]] = None) -> 'StatusRequest':
636
+ """
637
+ Factory for creating new status change request.
638
+
639
+ REQ-tv-d00010-F: Auto-generates IDs and timestamps.
640
+
641
+ Args:
642
+ req_id: Requirement ID
643
+ from_status: Current status
644
+ to_status: Requested new status
645
+ requested_by: Username making request
646
+ justification: Reason for change
647
+ required_approvers: Override default approvers
648
+ """
649
+ # Determine required approvers from defaults if not provided
650
+ if required_approvers is None:
651
+ transition_key = f"{from_status}->{to_status}"
652
+ required_approvers = DEFAULT_APPROVAL_RULES.get(
653
+ transition_key, ["product_owner"]
654
+ )
655
+
656
+ return cls(
657
+ requestId=generate_uuid(),
658
+ reqId=req_id,
659
+ type="status_change",
660
+ fromStatus=from_status,
661
+ toStatus=to_status,
662
+ requestedBy=requested_by,
663
+ requestedAt=now_iso(),
664
+ justification=justification,
665
+ approvals=[],
666
+ requiredApprovers=required_approvers,
667
+ state=RequestState.PENDING.value
668
+ )
669
+
670
+ def add_approval(self, user: str, decision: str,
671
+ comment: Optional[str] = None) -> Approval:
672
+ """Add an approval to the request"""
673
+ approval = Approval.create(user, decision, comment)
674
+ self.approvals.append(approval)
675
+ self._update_state()
676
+ return approval
677
+
678
+ def _update_state(self) -> None:
679
+ """
680
+ Update state based on approvals.
681
+
682
+ REQ-tv-d00010-I: State automatically calculated from approval votes.
683
+ """
684
+ if self.state == RequestState.APPLIED.value:
685
+ return # Already applied, don't change
686
+
687
+ # Check for any rejections
688
+ for approval in self.approvals:
689
+ if approval.decision == ApprovalDecision.REJECT.value:
690
+ self.state = RequestState.REJECTED.value
691
+ return
692
+
693
+ # Check if all required approvers have approved
694
+ approved_users = {
695
+ a.user for a in self.approvals
696
+ if a.decision == ApprovalDecision.APPROVE.value
697
+ }
698
+
699
+ # Check if required approvers are satisfied
700
+ all_approved = all(
701
+ approver in approved_users
702
+ for approver in self.requiredApprovers
703
+ )
704
+
705
+ if all_approved:
706
+ self.state = RequestState.APPROVED.value
707
+ else:
708
+ self.state = RequestState.PENDING.value
709
+
710
+ def mark_applied(self) -> None:
711
+ """Mark the request as applied"""
712
+ if self.state != RequestState.APPROVED.value:
713
+ raise ValueError("Can only apply approved requests")
714
+ self.state = RequestState.APPLIED.value
715
+
716
+ def validate(self) -> Tuple[bool, List[str]]:
717
+ """Validate status request"""
718
+ errors = []
719
+
720
+ if not self.requestId:
721
+ errors.append("requestId is required")
722
+ if not validate_req_id(self.reqId):
723
+ errors.append(f"Invalid requirement ID: {self.reqId}")
724
+ if self.type != "status_change":
725
+ errors.append(f"Invalid type: {self.type}")
726
+ if self.fromStatus not in VALID_REQ_STATUSES:
727
+ errors.append(f"Invalid fromStatus: {self.fromStatus}")
728
+ if self.toStatus not in VALID_REQ_STATUSES:
729
+ errors.append(f"Invalid toStatus: {self.toStatus}")
730
+ if self.fromStatus == self.toStatus:
731
+ errors.append("fromStatus and toStatus must be different")
732
+ if not self.requestedBy:
733
+ errors.append("requestedBy is required")
734
+ if not self.justification:
735
+ errors.append("justification is required")
736
+ if self.state not in [s.value for s in RequestState]:
737
+ errors.append(f"Invalid state: {self.state}")
738
+
739
+ # Validate approvals
740
+ for i, approval in enumerate(self.approvals):
741
+ approval_valid, approval_errors = approval.validate()
742
+ errors.extend([f"Approval[{i}]: {e}" for e in approval_errors])
743
+
744
+ return len(errors) == 0, errors
745
+
746
+ def to_dict(self) -> Dict[str, Any]:
747
+ """Convert to JSON-serializable dictionary"""
748
+ return {
749
+ 'requestId': self.requestId,
750
+ 'reqId': self.reqId,
751
+ 'type': self.type,
752
+ 'fromStatus': self.fromStatus,
753
+ 'toStatus': self.toStatus,
754
+ 'requestedBy': self.requestedBy,
755
+ 'requestedAt': self.requestedAt,
756
+ 'justification': self.justification,
757
+ 'approvals': [a.to_dict() for a in self.approvals],
758
+ 'requiredApprovers': self.requiredApprovers,
759
+ 'state': self.state
760
+ }
761
+
762
+ @classmethod
763
+ def from_dict(cls, data: Dict[str, Any]) -> 'StatusRequest':
764
+ """Create from dictionary"""
765
+ return cls(
766
+ requestId=data['requestId'],
767
+ reqId=data['reqId'],
768
+ type=data['type'],
769
+ fromStatus=data['fromStatus'],
770
+ toStatus=data['toStatus'],
771
+ requestedBy=data['requestedBy'],
772
+ requestedAt=data['requestedAt'],
773
+ justification=data['justification'],
774
+ approvals=[Approval.from_dict(a) for a in data.get('approvals', [])],
775
+ requiredApprovers=data.get('requiredApprovers', []),
776
+ state=data['state']
777
+ )
778
+
779
+
780
+ @dataclass
781
+ class ReviewSession:
782
+ """
783
+ Groups review activity for a user.
784
+
785
+ Sessions help organize reviews and track progress over time.
786
+ """
787
+ sessionId: str # UUID
788
+ user: str # Username
789
+ name: str # Session name (e.g., "Sprint 23 Review")
790
+ createdAt: str # ISO 8601 datetime
791
+ description: Optional[str] = None
792
+
793
+ @classmethod
794
+ def create(cls, user: str, name: str,
795
+ description: Optional[str] = None) -> 'ReviewSession':
796
+ """Factory for creating new session"""
797
+ return cls(
798
+ sessionId=generate_uuid(),
799
+ user=user,
800
+ name=name,
801
+ createdAt=now_iso(),
802
+ description=description
803
+ )
804
+
805
+ def validate(self) -> Tuple[bool, List[str]]:
806
+ """Validate session"""
807
+ errors = []
808
+
809
+ if not self.sessionId:
810
+ errors.append("sessionId is required")
811
+ if not self.user:
812
+ errors.append("user is required")
813
+ if not self.name:
814
+ errors.append("name is required")
815
+ if not self.createdAt:
816
+ errors.append("createdAt is required")
817
+
818
+ return len(errors) == 0, errors
819
+
820
+ def to_dict(self) -> Dict[str, Any]:
821
+ """Convert to JSON-serializable dictionary"""
822
+ result: Dict[str, Any] = {
823
+ 'sessionId': self.sessionId,
824
+ 'user': self.user,
825
+ 'name': self.name,
826
+ 'createdAt': self.createdAt
827
+ }
828
+ if self.description is not None:
829
+ result['description'] = self.description
830
+ return result
831
+
832
+ @classmethod
833
+ def from_dict(cls, data: Dict[str, Any]) -> 'ReviewSession':
834
+ """Create from dictionary"""
835
+ return cls(
836
+ sessionId=data['sessionId'],
837
+ user=data['user'],
838
+ name=data['name'],
839
+ createdAt=data['createdAt'],
840
+ description=data.get('description')
841
+ )
842
+
843
+
844
+ @dataclass
845
+ class ReviewConfig:
846
+ """
847
+ System configuration for the review system.
848
+ """
849
+ approvalRules: Dict[str, List[str]] # Status transition -> required approvers
850
+ pushOnComment: bool = True # Auto git push when adding comments
851
+ autoFetchOnOpen: bool = True # Auto git fetch when opening reviews
852
+
853
+ @classmethod
854
+ def default(cls) -> 'ReviewConfig':
855
+ """Factory for default configuration"""
856
+ return cls(
857
+ approvalRules=DEFAULT_APPROVAL_RULES.copy(),
858
+ pushOnComment=True,
859
+ autoFetchOnOpen=True
860
+ )
861
+
862
+ def get_required_approvers(self, from_status: str, to_status: str) -> List[str]:
863
+ """Get required approvers for a status transition"""
864
+ key = f"{from_status}->{to_status}"
865
+ return self.approvalRules.get(key, ["product_owner"])
866
+
867
+ def validate(self) -> Tuple[bool, List[str]]:
868
+ """Validate configuration"""
869
+ errors = []
870
+
871
+ # Validate approval rules
872
+ for transition, approvers in self.approvalRules.items():
873
+ parts = transition.split('->')
874
+ if len(parts) != 2:
875
+ errors.append(f"Invalid transition format: {transition}")
876
+ continue
877
+ from_status, to_status = parts
878
+ if from_status not in VALID_REQ_STATUSES:
879
+ errors.append(f"Invalid from status in {transition}: {from_status}")
880
+ if to_status not in VALID_REQ_STATUSES:
881
+ errors.append(f"Invalid to status in {transition}: {to_status}")
882
+ if not approvers:
883
+ errors.append(f"Empty approvers for {transition}")
884
+
885
+ return len(errors) == 0, errors
886
+
887
+ def to_dict(self) -> Dict[str, Any]:
888
+ """Convert to JSON-serializable dictionary"""
889
+ return {
890
+ 'approvalRules': self.approvalRules,
891
+ 'pushOnComment': self.pushOnComment,
892
+ 'autoFetchOnOpen': self.autoFetchOnOpen
893
+ }
894
+
895
+ @classmethod
896
+ def from_dict(cls, data: Dict[str, Any]) -> 'ReviewConfig':
897
+ """Create from dictionary"""
898
+ return cls(
899
+ approvalRules=data.get('approvalRules', DEFAULT_APPROVAL_RULES.copy()),
900
+ pushOnComment=data.get('pushOnComment', True),
901
+ autoFetchOnOpen=data.get('autoFetchOnOpen', True)
902
+ )
903
+
904
+
905
+ # Archive reason enum values
906
+ ARCHIVE_REASON_RESOLVED = "resolved"
907
+ ARCHIVE_REASON_DELETED = "deleted"
908
+ ARCHIVE_REASON_MANUAL = "manual"
909
+
910
+
911
+ @dataclass
912
+ class ReviewPackage:
913
+ """
914
+ Named collection of REQs under review.
915
+
916
+ Packages group related requirements for coordinated review.
917
+ Supports audit trail and archival metadata.
918
+
919
+ REQ-d00095: Review Package Management
920
+ REQ-d00097: Review Package Archival
921
+ REQ-d00098: Review Git Audit Trail
922
+ """
923
+ packageId: str # UUID
924
+ name: str
925
+ description: str
926
+ reqIds: List[str]
927
+ createdBy: str # Username
928
+ createdAt: str # ISO 8601 datetime
929
+
930
+ # REQ-d00098: Git Audit Trail
931
+ branchName: Optional[str] = None # Git branch when package created
932
+ creationCommitHash: Optional[str] = None # HEAD commit when created
933
+ lastReviewedCommitHash: Optional[str] = None # Updated on comment activity
934
+
935
+ # REQ-d00097: Archive metadata
936
+ archivedAt: Optional[str] = None # ISO 8601 datetime when archived
937
+ archivedBy: Optional[str] = None # Username who triggered archive
938
+ archiveReason: Optional[str] = None # "resolved", "deleted", or "manual"
939
+
940
+ # Deprecated: kept for backward compatibility during migration
941
+ isDefault: bool = False
942
+
943
+ @classmethod
944
+ def create(cls, name: str, description: str, created_by: str,
945
+ branch_name: Optional[str] = None,
946
+ commit_hash: Optional[str] = None) -> 'ReviewPackage':
947
+ """
948
+ Factory for creating new package.
949
+
950
+ REQ-tv-d00010-F: Auto-generates IDs and timestamps.
951
+ REQ-d00098: Records git context when available.
952
+
953
+ Args:
954
+ name: Package display name
955
+ description: Package description
956
+ created_by: Username of creator
957
+ branch_name: Current git branch (for audit trail)
958
+ commit_hash: Current HEAD commit hash (for audit trail)
959
+ """
960
+ return cls(
961
+ packageId=generate_uuid(),
962
+ name=name,
963
+ description=description,
964
+ reqIds=[],
965
+ createdBy=created_by,
966
+ createdAt=now_iso(),
967
+ branchName=branch_name,
968
+ creationCommitHash=commit_hash,
969
+ lastReviewedCommitHash=commit_hash,
970
+ isDefault=False
971
+ )
972
+
973
+ @classmethod
974
+ def create_default(cls) -> 'ReviewPackage':
975
+ """
976
+ Create the default package.
977
+
978
+ DEPRECATED: Default packages are no longer recommended per REQ-d00095-B.
979
+ This method is kept for backward compatibility during migration.
980
+ """
981
+ import warnings
982
+ warnings.warn(
983
+ "create_default() is deprecated. Packages should be explicitly created.",
984
+ DeprecationWarning,
985
+ stacklevel=2
986
+ )
987
+ return cls(
988
+ packageId="default",
989
+ name="Default",
990
+ description="REQs manually set to Review status",
991
+ reqIds=[],
992
+ createdBy="system",
993
+ createdAt=now_iso(),
994
+ isDefault=True
995
+ )
996
+
997
+ def update_last_reviewed_commit(self, commit_hash: str) -> None:
998
+ """
999
+ Update the last reviewed commit hash.
1000
+
1001
+ REQ-d00098-C: Updated on each comment activity.
1002
+ """
1003
+ self.lastReviewedCommitHash = commit_hash
1004
+
1005
+ def archive(self, user: str, reason: str) -> None:
1006
+ """
1007
+ Mark package as archived.
1008
+
1009
+ REQ-d00097-C: Sets archive metadata.
1010
+
1011
+ Args:
1012
+ user: Username who triggered archive
1013
+ reason: One of "resolved", "deleted", "manual"
1014
+ """
1015
+ if reason not in (ARCHIVE_REASON_RESOLVED, ARCHIVE_REASON_DELETED, ARCHIVE_REASON_MANUAL):
1016
+ raise ValueError(f"Invalid archive reason: {reason}")
1017
+ self.archivedAt = now_iso()
1018
+ self.archivedBy = user
1019
+ self.archiveReason = reason
1020
+
1021
+ @property
1022
+ def is_archived(self) -> bool:
1023
+ """Check if package is archived."""
1024
+ return self.archivedAt is not None
1025
+
1026
+ def validate(self) -> Tuple[bool, List[str]]:
1027
+ """Validate package"""
1028
+ errors = []
1029
+
1030
+ if not self.packageId:
1031
+ errors.append("packageId is required")
1032
+ if not self.name:
1033
+ errors.append("name is required")
1034
+ if not self.createdBy:
1035
+ errors.append("createdBy is required")
1036
+ if not self.createdAt:
1037
+ errors.append("createdAt is required")
1038
+
1039
+ # Validate archive state consistency
1040
+ if self.archivedAt:
1041
+ if not self.archivedBy:
1042
+ errors.append("archivedBy is required when archivedAt is set")
1043
+ if not self.archiveReason:
1044
+ errors.append("archiveReason is required when archivedAt is set")
1045
+ elif self.archiveReason not in (ARCHIVE_REASON_RESOLVED, ARCHIVE_REASON_DELETED, ARCHIVE_REASON_MANUAL):
1046
+ errors.append(f"Invalid archiveReason: {self.archiveReason}")
1047
+
1048
+ return len(errors) == 0, errors
1049
+
1050
+ def to_dict(self) -> Dict[str, Any]:
1051
+ """Convert to JSON-serializable dictionary"""
1052
+ result: Dict[str, Any] = {
1053
+ 'packageId': self.packageId,
1054
+ 'name': self.name,
1055
+ 'description': self.description,
1056
+ 'reqIds': self.reqIds.copy(),
1057
+ 'createdBy': self.createdBy,
1058
+ 'createdAt': self.createdAt,
1059
+ }
1060
+
1061
+ # REQ-d00098: Git audit trail (only include if set)
1062
+ if self.branchName is not None:
1063
+ result['branchName'] = self.branchName
1064
+ if self.creationCommitHash is not None:
1065
+ result['creationCommitHash'] = self.creationCommitHash
1066
+ if self.lastReviewedCommitHash is not None:
1067
+ result['lastReviewedCommitHash'] = self.lastReviewedCommitHash
1068
+
1069
+ # REQ-d00097: Archive metadata (only include if archived)
1070
+ if self.archivedAt is not None:
1071
+ result['archivedAt'] = self.archivedAt
1072
+ result['archivedBy'] = self.archivedBy
1073
+ result['archiveReason'] = self.archiveReason
1074
+
1075
+ # Deprecated field - still included for backward compatibility
1076
+ if self.isDefault:
1077
+ result['isDefault'] = self.isDefault
1078
+
1079
+ return result
1080
+
1081
+ @classmethod
1082
+ def from_dict(cls, data: Dict[str, Any]) -> 'ReviewPackage':
1083
+ """Create from dictionary"""
1084
+ return cls(
1085
+ packageId=data.get('packageId', ''),
1086
+ name=data.get('name', ''),
1087
+ description=data.get('description', ''),
1088
+ reqIds=data.get('reqIds', []).copy(),
1089
+ createdBy=data.get('createdBy', ''),
1090
+ createdAt=data.get('createdAt', ''),
1091
+ # REQ-d00098: Git audit trail
1092
+ branchName=data.get('branchName'),
1093
+ creationCommitHash=data.get('creationCommitHash'),
1094
+ lastReviewedCommitHash=data.get('lastReviewedCommitHash'),
1095
+ # REQ-d00097: Archive metadata
1096
+ archivedAt=data.get('archivedAt'),
1097
+ archivedBy=data.get('archivedBy'),
1098
+ archiveReason=data.get('archiveReason'),
1099
+ # Deprecated field
1100
+ isDefault=data.get('isDefault', False)
1101
+ )
1102
+
1103
+
1104
+ # =============================================================================
1105
+ # Container Classes for JSON File Contents
1106
+ # REQ-tv-d00010-G: Container classes with version tracking
1107
+ # =============================================================================
1108
+
1109
+ @dataclass
1110
+ class ThreadsFile:
1111
+ """Container for threads.json file contents"""
1112
+ reqId: str
1113
+ threads: List[Thread]
1114
+ version: str = "1.0"
1115
+
1116
+ def to_dict(self) -> Dict[str, Any]:
1117
+ """Convert to JSON-serializable dictionary"""
1118
+ return {
1119
+ 'version': self.version,
1120
+ 'reqId': self.reqId,
1121
+ 'threads': [t.to_dict() for t in self.threads]
1122
+ }
1123
+
1124
+ @classmethod
1125
+ def from_dict(cls, data: Dict[str, Any]) -> 'ThreadsFile':
1126
+ """Create from dictionary"""
1127
+ return cls(
1128
+ version=data.get('version', '1.0'),
1129
+ reqId=data['reqId'],
1130
+ threads=[Thread.from_dict(t) for t in data.get('threads', [])]
1131
+ )
1132
+
1133
+
1134
+ @dataclass
1135
+ class StatusFile:
1136
+ """Container for status.json file contents"""
1137
+ reqId: str
1138
+ requests: List[StatusRequest]
1139
+ version: str = "1.0"
1140
+
1141
+ def to_dict(self) -> Dict[str, Any]:
1142
+ """Convert to JSON-serializable dictionary"""
1143
+ return {
1144
+ 'version': self.version,
1145
+ 'reqId': self.reqId,
1146
+ 'requests': [r.to_dict() for r in self.requests]
1147
+ }
1148
+
1149
+ @classmethod
1150
+ def from_dict(cls, data: Dict[str, Any]) -> 'StatusFile':
1151
+ """Create from dictionary"""
1152
+ return cls(
1153
+ version=data.get('version', '1.0'),
1154
+ reqId=data['reqId'],
1155
+ requests=[StatusRequest.from_dict(r) for r in data.get('requests', [])]
1156
+ )
1157
+
1158
+
1159
+ @dataclass
1160
+ class PackagesFile:
1161
+ """Container for packages.json file contents"""
1162
+ packages: List[ReviewPackage]
1163
+ activePackageId: Optional[str] = None
1164
+ version: str = "1.0"
1165
+
1166
+ def to_dict(self) -> Dict[str, Any]:
1167
+ """Convert to JSON-serializable dictionary"""
1168
+ return {
1169
+ 'version': self.version,
1170
+ 'packages': [p.to_dict() for p in self.packages],
1171
+ 'activePackageId': self.activePackageId
1172
+ }
1173
+
1174
+ @classmethod
1175
+ def from_dict(cls, data: Dict[str, Any]) -> 'PackagesFile':
1176
+ """Create from dictionary"""
1177
+ packages = [ReviewPackage.from_dict(p) for p in data.get('packages', [])]
1178
+ return cls(
1179
+ version=data.get('version', '1.0'),
1180
+ packages=packages,
1181
+ activePackageId=data.get('activePackageId')
1182
+ )
1183
+
1184
+ def get_default(self) -> Optional[ReviewPackage]:
1185
+ """Get the default package"""
1186
+ for pkg in self.packages:
1187
+ if pkg.isDefault:
1188
+ return pkg
1189
+ return None
1190
+
1191
+ def get_active(self) -> Optional[ReviewPackage]:
1192
+ """Get the currently active package"""
1193
+ if not self.activePackageId:
1194
+ return None
1195
+ for pkg in self.packages:
1196
+ if pkg.packageId == self.activePackageId:
1197
+ return pkg
1198
+ return None
1199
+
1200
+ def get_by_id(self, package_id: str) -> Optional[ReviewPackage]:
1201
+ """Get a package by ID"""
1202
+ for pkg in self.packages:
1203
+ if pkg.packageId == package_id:
1204
+ return pkg
1205
+ return None