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.
- elspais/cli.py +99 -1
- elspais/commands/hash_cmd.py +72 -26
- elspais/commands/reformat_cmd.py +458 -0
- elspais/commands/trace.py +157 -3
- elspais/commands/validate.py +44 -16
- elspais/core/models.py +2 -0
- elspais/core/parser.py +68 -24
- elspais/reformat/__init__.py +50 -0
- elspais/reformat/detector.py +119 -0
- elspais/reformat/hierarchy.py +246 -0
- elspais/reformat/line_breaks.py +220 -0
- elspais/reformat/prompts.py +123 -0
- elspais/reformat/transformer.py +264 -0
- elspais/sponsors/__init__.py +432 -0
- elspais/trace_view/__init__.py +54 -0
- elspais/trace_view/coverage.py +183 -0
- elspais/trace_view/generators/__init__.py +12 -0
- elspais/trace_view/generators/base.py +329 -0
- elspais/trace_view/generators/csv.py +122 -0
- elspais/trace_view/generators/markdown.py +175 -0
- elspais/trace_view/html/__init__.py +31 -0
- elspais/trace_view/html/generator.py +1006 -0
- elspais/trace_view/html/templates/base.html +283 -0
- elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
- elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
- elspais/trace_view/html/templates/components/legend_modal.html +69 -0
- elspais/trace_view/html/templates/components/review_panel.html +118 -0
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
- elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
- elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
- elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
- elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
- elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
- elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
- elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
- elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
- elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
- elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
- elspais/trace_view/html/templates/partials/scripts.js +1741 -0
- elspais/trace_view/html/templates/partials/styles.css +1756 -0
- elspais/trace_view/models.py +353 -0
- elspais/trace_view/review/__init__.py +60 -0
- elspais/trace_view/review/branches.py +1149 -0
- elspais/trace_view/review/models.py +1205 -0
- elspais/trace_view/review/position.py +609 -0
- elspais/trace_view/review/server.py +1056 -0
- elspais/trace_view/review/status.py +470 -0
- elspais/trace_view/review/storage.py +1367 -0
- elspais/trace_view/scanning.py +213 -0
- elspais/trace_view/specs/README.md +84 -0
- elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
- elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
- elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
- elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
- elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
- elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
- elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
- elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
- elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
- elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
- elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
- elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
- elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
- elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
- {elspais-0.9.3.dist-info → elspais-0.11.0.dist-info}/METADATA +33 -18
- elspais-0.11.0.dist-info/RECORD +101 -0
- elspais-0.9.3.dist-info/RECORD +0 -40
- {elspais-0.9.3.dist-info → elspais-0.11.0.dist-info}/WHEEL +0 -0
- {elspais-0.9.3.dist-info → elspais-0.11.0.dist-info}/entry_points.txt +0 -0
- {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)
|