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,609 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Position Resolution Module for trace_view
4
+
5
+ Resolves comment positions within requirement text, handling content drift
6
+ when REQ hashes don't match. Uses fallback strategies to locate anchors
7
+ with varying confidence levels.
8
+
9
+ IMPLEMENTS REQUIREMENTS:
10
+ REQ-tv-d00012: Position Resolution
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ from .models import CommentPosition, PositionType
18
+
19
+
20
+ # =============================================================================
21
+ # Enums
22
+ # REQ-tv-d00012-B: Confidence levels as string enums
23
+ # =============================================================================
24
+
25
+ class ResolutionConfidence(str, Enum):
26
+ """
27
+ Confidence level for resolved position.
28
+
29
+ REQ-tv-d00012-B: EXACT (hash matches), APPROXIMATE (fallback matched),
30
+ or UNANCHORED (no match found).
31
+ """
32
+ EXACT = "exact"
33
+ APPROXIMATE = "approximate"
34
+ UNANCHORED = "unanchored"
35
+
36
+
37
+ # =============================================================================
38
+ # Data Classes
39
+ # =============================================================================
40
+
41
+ @dataclass
42
+ class ResolvedPosition:
43
+ """
44
+ Result of resolving a CommentPosition against current REQ content.
45
+
46
+ REQ-tv-d00012-A: Resolves CommentPosition anchors to current document coordinates.
47
+ REQ-tv-d00012-I: Includes resolutionPath describing the fallback strategy used.
48
+
49
+ Contains all information needed to display a comment at its resolved
50
+ location, including confidence level and original position for reference.
51
+ """
52
+ type: str # Resolved position type (PositionType value)
53
+ confidence: str # ResolutionConfidence value
54
+ lineNumber: Optional[int] # Resolved line (1-based), None for general
55
+ lineRange: Optional[Tuple[int, int]] # Resolved line range (start, end), 1-based
56
+ charRange: Optional[Tuple[int, int]] # Character offsets in body (start, end), 0-based
57
+ matchedText: Optional[str] # The text that was matched (for debugging)
58
+ originalPosition: CommentPosition # Original position for reference
59
+ resolutionPath: str # How position was resolved (for debugging)
60
+
61
+ @classmethod
62
+ def create_exact(
63
+ cls,
64
+ position_type: str,
65
+ line_number: Optional[int],
66
+ line_range: Optional[Tuple[int, int]],
67
+ char_range: Optional[Tuple[int, int]],
68
+ matched_text: Optional[str],
69
+ original: CommentPosition
70
+ ) -> 'ResolvedPosition':
71
+ """
72
+ Factory for exact resolution (hash matched).
73
+
74
+ REQ-tv-d00012-C: Hash match yields EXACT confidence.
75
+ """
76
+ return cls(
77
+ type=position_type,
78
+ confidence=ResolutionConfidence.EXACT.value,
79
+ lineNumber=line_number,
80
+ lineRange=line_range,
81
+ charRange=char_range,
82
+ matchedText=matched_text,
83
+ originalPosition=original,
84
+ resolutionPath="hash_match"
85
+ )
86
+
87
+ @classmethod
88
+ def create_approximate(
89
+ cls,
90
+ position_type: str,
91
+ line_number: Optional[int],
92
+ line_range: Optional[Tuple[int, int]],
93
+ char_range: Optional[Tuple[int, int]],
94
+ matched_text: Optional[str],
95
+ original: CommentPosition,
96
+ resolution_path: str
97
+ ) -> 'ResolvedPosition':
98
+ """
99
+ Factory for approximate resolution (fallback succeeded).
100
+
101
+ REQ-tv-d00012-D: Fallback resolution yields APPROXIMATE confidence.
102
+ """
103
+ return cls(
104
+ type=position_type,
105
+ confidence=ResolutionConfidence.APPROXIMATE.value,
106
+ lineNumber=line_number,
107
+ lineRange=line_range,
108
+ charRange=char_range,
109
+ matchedText=matched_text,
110
+ originalPosition=original,
111
+ resolutionPath=resolution_path
112
+ )
113
+
114
+ @classmethod
115
+ def create_unanchored(cls, original: CommentPosition) -> 'ResolvedPosition':
116
+ """
117
+ Factory for unanchored resolution (all fallbacks failed).
118
+
119
+ REQ-tv-d00012-J: When no fallback succeeds, resolve as UNANCHORED
120
+ with original position preserved.
121
+ """
122
+ return cls(
123
+ type=PositionType.GENERAL.value,
124
+ confidence=ResolutionConfidence.UNANCHORED.value,
125
+ lineNumber=None,
126
+ lineRange=None,
127
+ charRange=None,
128
+ matchedText=None,
129
+ originalPosition=original,
130
+ resolutionPath="fallback_exhausted"
131
+ )
132
+
133
+ def validate(self) -> Tuple[bool, List[str]]:
134
+ """Validate resolved position fields"""
135
+ errors = []
136
+
137
+ if self.type not in [pt.value for pt in PositionType]:
138
+ errors.append(f"Invalid position type: {self.type}")
139
+
140
+ if self.confidence not in [cl.value for cl in ResolutionConfidence]:
141
+ errors.append(f"Invalid confidence level: {self.confidence}")
142
+
143
+ # Line numbers must be positive if present
144
+ if self.lineNumber is not None and self.lineNumber < 1:
145
+ errors.append("lineNumber must be positive")
146
+
147
+ # Line range validation
148
+ if self.lineRange is not None:
149
+ if len(self.lineRange) != 2:
150
+ errors.append("lineRange must be tuple of (start, end)")
151
+ elif self.lineRange[0] < 1 or self.lineRange[1] < self.lineRange[0]:
152
+ errors.append("Invalid lineRange: start must be >= 1 and end >= start")
153
+
154
+ # Char range validation
155
+ if self.charRange is not None:
156
+ if len(self.charRange) != 2:
157
+ errors.append("charRange must be tuple of (start, end)")
158
+ elif self.charRange[0] < 0 or self.charRange[1] < self.charRange[0]:
159
+ errors.append("Invalid charRange: start must be >= 0 and end >= start")
160
+
161
+ return len(errors) == 0, errors
162
+
163
+ def to_dict(self) -> Dict[str, Any]:
164
+ """Convert to JSON-serializable dictionary"""
165
+ result: Dict[str, Any] = {
166
+ 'type': self.type,
167
+ 'confidence': self.confidence,
168
+ 'resolutionPath': self.resolutionPath,
169
+ 'originalPosition': self.originalPosition.to_dict()
170
+ }
171
+ if self.lineNumber is not None:
172
+ result['lineNumber'] = self.lineNumber
173
+ if self.lineRange is not None:
174
+ result['lineRange'] = list(self.lineRange)
175
+ if self.charRange is not None:
176
+ result['charRange'] = list(self.charRange)
177
+ if self.matchedText is not None:
178
+ result['matchedText'] = self.matchedText
179
+ return result
180
+
181
+ @classmethod
182
+ def from_dict(cls, data: Dict[str, Any]) -> 'ResolvedPosition':
183
+ """Create from dictionary (JSON deserialization)"""
184
+ line_range = data.get('lineRange')
185
+ if line_range is not None:
186
+ line_range = tuple(line_range)
187
+ char_range = data.get('charRange')
188
+ if char_range is not None:
189
+ char_range = tuple(char_range)
190
+ return cls(
191
+ type=data['type'],
192
+ confidence=data['confidence'],
193
+ lineNumber=data.get('lineNumber'),
194
+ lineRange=line_range,
195
+ charRange=char_range,
196
+ matchedText=data.get('matchedText'),
197
+ originalPosition=CommentPosition.from_dict(data['originalPosition']),
198
+ resolutionPath=data.get('resolutionPath', 'unknown')
199
+ )
200
+
201
+
202
+ # =============================================================================
203
+ # Helper Functions
204
+ # =============================================================================
205
+
206
+ def find_line_in_text(text: str, line_number: int) -> Optional[Tuple[int, int]]:
207
+ """
208
+ Find character range for a specific line in text.
209
+
210
+ Args:
211
+ text: The text to search in
212
+ line_number: 1-based line number to find
213
+
214
+ Returns:
215
+ Tuple of (start_offset, end_offset) for the line, or None if line doesn't exist.
216
+ end_offset points to the character after the line content (before newline or EOF).
217
+ """
218
+ if not text or line_number < 1:
219
+ return None
220
+
221
+ lines = text.split('\n')
222
+
223
+ if line_number > len(lines):
224
+ return None
225
+
226
+ # Calculate start offset by summing lengths of previous lines + newlines
227
+ start_offset = 0
228
+ for i in range(line_number - 1):
229
+ start_offset += len(lines[i]) + 1 # +1 for newline
230
+
231
+ # End offset is start + length of this line
232
+ end_offset = start_offset + len(lines[line_number - 1])
233
+
234
+ return (start_offset, end_offset)
235
+
236
+
237
+ def find_context_in_text(text: str, context: str) -> Optional[Tuple[int, int]]:
238
+ """
239
+ Find character range where context appears in text.
240
+
241
+ Args:
242
+ text: The text to search in
243
+ context: The substring to find
244
+
245
+ Returns:
246
+ Tuple of (start_offset, end_offset) for first occurrence, or None if not found.
247
+ """
248
+ if not text or not context:
249
+ return None
250
+
251
+ index = text.find(context)
252
+ if index == -1:
253
+ return None
254
+
255
+ return (index, index + len(context))
256
+
257
+
258
+ def find_keyword_occurrence(
259
+ text: str,
260
+ keyword: str,
261
+ occurrence: int
262
+ ) -> Optional[Tuple[int, int]]:
263
+ """
264
+ Find character range of the Nth occurrence of a keyword.
265
+
266
+ REQ-tv-d00012-G: For WORD positions, find the Nth occurrence based on keywordOccurrence.
267
+
268
+ Args:
269
+ text: The text to search in
270
+ keyword: The word/phrase to find
271
+ occurrence: 1-based occurrence index (1 = first, 2 = second, etc.)
272
+
273
+ Returns:
274
+ Tuple of (start_offset, end_offset) for the Nth occurrence, or None if not found.
275
+ """
276
+ if not text or not keyword or occurrence < 1:
277
+ return None
278
+
279
+ current_occurrence = 0
280
+ start_index = 0
281
+
282
+ while start_index < len(text):
283
+ index = text.find(keyword, start_index)
284
+ if index == -1:
285
+ break
286
+
287
+ current_occurrence += 1
288
+ if current_occurrence == occurrence:
289
+ return (index, index + len(keyword))
290
+
291
+ # Move past this occurrence to find next
292
+ start_index = index + 1
293
+
294
+ return None
295
+
296
+
297
+ def get_line_number_from_char_offset(text: str, char_offset: int) -> int:
298
+ """
299
+ Convert character offset to 1-based line number.
300
+
301
+ Args:
302
+ text: The text to analyze
303
+ char_offset: 0-based character offset
304
+
305
+ Returns:
306
+ 1-based line number containing the offset.
307
+ """
308
+ if not text or char_offset <= 0:
309
+ return 1
310
+
311
+ # Clamp offset to text length
312
+ char_offset = min(char_offset, len(text) - 1)
313
+
314
+ # Count newlines before offset
315
+ newline_count = text[:char_offset + 1].count('\n')
316
+
317
+ # Special case: if offset is exactly on a newline, it belongs to previous line
318
+ if char_offset < len(text) and text[char_offset] == '\n':
319
+ return newline_count # Don't add 1 since we're ON the newline
320
+
321
+ return newline_count + 1
322
+
323
+
324
+ def get_line_range_from_char_range(
325
+ text: str,
326
+ start: int,
327
+ end: int
328
+ ) -> Tuple[int, int]:
329
+ """
330
+ Convert character range to line range.
331
+
332
+ Args:
333
+ text: The text to analyze
334
+ start: Start character offset (0-based, inclusive)
335
+ end: End character offset (0-based, exclusive)
336
+
337
+ Returns:
338
+ Tuple of (start_line, end_line) as 1-based line numbers.
339
+ """
340
+ start_line = get_line_number_from_char_offset(text, start)
341
+ # Handle empty range (start == end) - both on same line
342
+ if start >= end:
343
+ return (start_line, start_line)
344
+ # end is exclusive, so use end-1 for the line calculation
345
+ end_line = get_line_number_from_char_offset(text, end - 1)
346
+
347
+ return (start_line, end_line)
348
+
349
+
350
+ def get_total_lines(text: str) -> int:
351
+ """
352
+ Get total number of lines in text.
353
+
354
+ Args:
355
+ text: The text to analyze
356
+
357
+ Returns:
358
+ Total line count (minimum 1 for non-empty text, 0 for empty)
359
+ """
360
+ if not text:
361
+ return 0
362
+ return text.count('\n') + 1
363
+
364
+
365
+ # =============================================================================
366
+ # Core Resolution Functions
367
+ # =============================================================================
368
+
369
+ def resolve_position(
370
+ position: CommentPosition,
371
+ content: str,
372
+ current_hash: str
373
+ ) -> ResolvedPosition:
374
+ """
375
+ Resolve a comment position against current requirement content.
376
+
377
+ REQ-tv-d00012-A: Resolves CommentPosition anchors to current document coordinates.
378
+
379
+ This is the main entry point for position resolution. It determines
380
+ the current location of a comment anchor, accounting for potential
381
+ content drift since the comment was created.
382
+
383
+ Args:
384
+ position: The original CommentPosition from the comment/thread
385
+ content: Current requirement body text
386
+ current_hash: Current 8-character hash of the requirement
387
+
388
+ Returns:
389
+ ResolvedPosition with confidence level and resolved coordinates
390
+
391
+ Resolution Strategy:
392
+ 1. If hash matches: Return exact position based on original type
393
+ 2. If hash differs, try fallbacks in order:
394
+ a. lineNumber (if within valid range)
395
+ b. fallbackContext (substring search)
396
+ c. keyword at keywordOccurrence
397
+ d. Fall back to general (unanchored)
398
+ """
399
+ # Handle empty text edge case
400
+ if not content:
401
+ return ResolvedPosition.create_unanchored(position)
402
+
403
+ # REQ-tv-d00012-H: GENERAL positions always resolve with EXACT confidence
404
+ if position.type == PositionType.GENERAL.value:
405
+ return _resolve_general(position, content)
406
+
407
+ # REQ-tv-d00012-C: Check if hash matches (exact resolution) - case insensitive
408
+ hash_matches = position.hashWhenCreated.lower() == current_hash.lower()
409
+
410
+ if hash_matches:
411
+ return _resolve_exact(position, content)
412
+ else:
413
+ # REQ-tv-d00012-D: Hash differs, attempt fallback resolution
414
+ return _resolve_with_fallback(position, content)
415
+
416
+
417
+ def _resolve_general(
418
+ position: CommentPosition,
419
+ content: str
420
+ ) -> ResolvedPosition:
421
+ """
422
+ Resolve GENERAL position type.
423
+
424
+ REQ-tv-d00012-H: GENERAL positions always resolve with EXACT confidence
425
+ since they apply to the entire requirement.
426
+ """
427
+ total_lines = get_total_lines(content)
428
+ return ResolvedPosition.create_exact(
429
+ position_type=PositionType.GENERAL.value,
430
+ line_number=None,
431
+ line_range=(1, total_lines) if total_lines > 0 else None,
432
+ char_range=(0, len(content)) if content else None,
433
+ matched_text=None,
434
+ original=position
435
+ )
436
+
437
+
438
+ def _resolve_exact(
439
+ position: CommentPosition,
440
+ content: str
441
+ ) -> ResolvedPosition:
442
+ """
443
+ Resolve position when hash matches (exact confidence).
444
+
445
+ REQ-tv-d00012-C: When the document hash matches, the position resolves
446
+ with EXACT confidence using stored coordinates.
447
+
448
+ Trusts the original position data since content hasn't changed.
449
+ """
450
+ pos_type = position.type
451
+
452
+ if pos_type == PositionType.GENERAL.value:
453
+ return _resolve_general(position, content)
454
+
455
+ elif pos_type == PositionType.LINE.value:
456
+ line_num = position.lineNumber
457
+ char_range = find_line_in_text(content, line_num)
458
+ matched_text = None
459
+
460
+ if char_range:
461
+ matched_text = content[char_range[0]:char_range[1]]
462
+
463
+ return ResolvedPosition.create_exact(
464
+ position_type=pos_type,
465
+ line_number=line_num,
466
+ line_range=(line_num, line_num) if line_num else None,
467
+ char_range=char_range,
468
+ matched_text=matched_text,
469
+ original=position
470
+ )
471
+
472
+ elif pos_type == PositionType.BLOCK.value:
473
+ line_range = position.lineRange
474
+ if line_range:
475
+ start_range = find_line_in_text(content, line_range[0])
476
+ end_range = find_line_in_text(content, line_range[1])
477
+
478
+ char_range = None
479
+ matched_text = None
480
+ if start_range and end_range:
481
+ char_range = (start_range[0], end_range[1])
482
+ matched_text = content[char_range[0]:char_range[1]]
483
+
484
+ return ResolvedPosition.create_exact(
485
+ position_type=pos_type,
486
+ line_number=line_range[0], # First line of block
487
+ line_range=line_range,
488
+ char_range=char_range,
489
+ matched_text=matched_text,
490
+ original=position
491
+ )
492
+ else:
493
+ return ResolvedPosition.create_unanchored(position)
494
+
495
+ elif pos_type == PositionType.WORD.value:
496
+ keyword = position.keyword
497
+ occurrence = position.keywordOccurrence or 1
498
+
499
+ if keyword:
500
+ char_range = find_keyword_occurrence(content, keyword, occurrence)
501
+
502
+ line_number = None
503
+ line_range_result = None
504
+ matched_text = keyword if char_range else None
505
+
506
+ if char_range:
507
+ line_number = get_line_number_from_char_offset(content, char_range[0])
508
+ line_range_result = get_line_range_from_char_range(
509
+ content, char_range[0], char_range[1]
510
+ )
511
+
512
+ return ResolvedPosition.create_exact(
513
+ position_type=pos_type,
514
+ line_number=line_number,
515
+ line_range=line_range_result,
516
+ char_range=char_range,
517
+ matched_text=matched_text,
518
+ original=position
519
+ )
520
+ else:
521
+ return ResolvedPosition.create_unanchored(position)
522
+
523
+ # Unknown type - fall back to unanchored
524
+ return ResolvedPosition.create_unanchored(position)
525
+
526
+
527
+ def _resolve_with_fallback(
528
+ position: CommentPosition,
529
+ content: str
530
+ ) -> ResolvedPosition:
531
+ """
532
+ Resolve position when hash differs (approximate confidence).
533
+
534
+ REQ-tv-d00012-D: When document hash differs, attempt fallback resolution.
535
+ REQ-tv-d00012-E: For LINE positions, search for context string.
536
+ REQ-tv-d00012-F: For BLOCK positions, search for context and expand.
537
+ REQ-tv-d00012-G: For WORD positions, search for keyword at Nth occurrence.
538
+
539
+ Tries fallback strategies in order:
540
+ 1. lineNumber (if within valid range)
541
+ 2. fallbackContext (substring search)
542
+ 3. keyword at keywordOccurrence
543
+ 4. Unanchored (general)
544
+ """
545
+ total_lines = get_total_lines(content)
546
+
547
+ # Strategy 1: Try lineNumber if available and in range
548
+ # For block positions, also try the first line of lineRange
549
+ line_to_try = position.lineNumber
550
+ if line_to_try is None and position.lineRange is not None:
551
+ line_to_try = position.lineRange[0]
552
+
553
+ if line_to_try is not None:
554
+ if 1 <= line_to_try <= total_lines:
555
+ char_range = find_line_in_text(content, line_to_try)
556
+ if char_range:
557
+ matched_text = content[char_range[0]:char_range[1]]
558
+ return ResolvedPosition.create_approximate(
559
+ position_type=PositionType.LINE.value,
560
+ line_number=line_to_try,
561
+ line_range=(line_to_try, line_to_try),
562
+ char_range=char_range,
563
+ matched_text=matched_text,
564
+ original=position,
565
+ resolution_path="fallback_line_number"
566
+ )
567
+
568
+ # Strategy 2: Try fallbackContext
569
+ # REQ-tv-d00012-E: For LINE positions, search for context string
570
+ if position.fallbackContext:
571
+ char_range = find_context_in_text(content, position.fallbackContext)
572
+ if char_range:
573
+ line_number = get_line_number_from_char_offset(content, char_range[0])
574
+ line_range = get_line_range_from_char_range(
575
+ content, char_range[0], char_range[1]
576
+ )
577
+ return ResolvedPosition.create_approximate(
578
+ position_type=PositionType.LINE.value, # Resolved to line
579
+ line_number=line_number,
580
+ line_range=line_range,
581
+ char_range=char_range,
582
+ matched_text=position.fallbackContext,
583
+ original=position,
584
+ resolution_path="fallback_context"
585
+ )
586
+
587
+ # Strategy 3: Try keyword occurrence
588
+ # REQ-tv-d00012-G: For WORD positions, search for keyword
589
+ if position.keyword:
590
+ occurrence = position.keywordOccurrence or 1
591
+ char_range = find_keyword_occurrence(content, position.keyword, occurrence)
592
+ if char_range:
593
+ line_number = get_line_number_from_char_offset(content, char_range[0])
594
+ line_range = get_line_range_from_char_range(
595
+ content, char_range[0], char_range[1]
596
+ )
597
+ return ResolvedPosition.create_approximate(
598
+ position_type=PositionType.WORD.value,
599
+ line_number=line_number,
600
+ line_range=line_range,
601
+ char_range=char_range,
602
+ matched_text=position.keyword,
603
+ original=position,
604
+ resolution_path="fallback_keyword"
605
+ )
606
+
607
+ # Strategy 4: Fall back to general (unanchored)
608
+ # REQ-tv-d00012-J: When no fallback succeeds, resolve as UNANCHORED
609
+ return ResolvedPosition.create_unanchored(position)