elspais 0.11.2__py3-none-any.whl → 0.43.5__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 (147) hide show
  1. elspais/__init__.py +1 -10
  2. elspais/{sponsors/__init__.py → associates.py} +102 -56
  3. elspais/cli.py +366 -69
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +118 -169
  6. elspais/commands/changed.py +12 -23
  7. elspais/commands/config_cmd.py +10 -13
  8. elspais/commands/edit.py +33 -13
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +161 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -115
  13. elspais/commands/init.py +99 -22
  14. elspais/commands/reformat_cmd.py +41 -433
  15. elspais/commands/rules_cmd.py +2 -2
  16. elspais/commands/trace.py +443 -324
  17. elspais/commands/validate.py +193 -411
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -2
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +45 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +1998 -244
  61. elspais/testing/__init__.py +3 -3
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/scanner.py +301 -12
  65. elspais/utilities/__init__.py +1 -0
  66. elspais/utilities/docs_loader.py +115 -0
  67. elspais/utilities/git.py +607 -0
  68. elspais/{core → utilities}/hasher.py +8 -22
  69. elspais/utilities/md_renderer.py +189 -0
  70. elspais/{core → utilities}/patterns.py +56 -51
  71. elspais/utilities/reference_config.py +626 -0
  72. elspais/validation/__init__.py +19 -0
  73. elspais/validation/format.py +264 -0
  74. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  75. elspais-0.43.5.dist-info/RECORD +80 -0
  76. elspais/config/defaults.py +0 -179
  77. elspais/config/loader.py +0 -494
  78. elspais/core/__init__.py +0 -21
  79. elspais/core/git.py +0 -346
  80. elspais/core/models.py +0 -320
  81. elspais/core/parser.py +0 -639
  82. elspais/core/rules.py +0 -509
  83. elspais/mcp/context.py +0 -172
  84. elspais/mcp/serializers.py +0 -112
  85. elspais/reformat/__init__.py +0 -50
  86. elspais/reformat/detector.py +0 -112
  87. elspais/reformat/hierarchy.py +0 -247
  88. elspais/reformat/line_breaks.py +0 -218
  89. elspais/reformat/prompts.py +0 -133
  90. elspais/reformat/transformer.py +0 -266
  91. elspais/trace_view/__init__.py +0 -55
  92. elspais/trace_view/coverage.py +0 -183
  93. elspais/trace_view/generators/__init__.py +0 -12
  94. elspais/trace_view/generators/base.py +0 -334
  95. elspais/trace_view/generators/csv.py +0 -118
  96. elspais/trace_view/generators/markdown.py +0 -170
  97. elspais/trace_view/html/__init__.py +0 -33
  98. elspais/trace_view/html/generator.py +0 -1140
  99. elspais/trace_view/html/templates/base.html +0 -283
  100. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  101. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  102. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  103. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  104. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  105. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  106. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  107. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  108. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  109. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  110. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  111. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  112. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  113. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  114. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  115. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  116. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  117. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  118. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  119. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  120. elspais/trace_view/models.py +0 -378
  121. elspais/trace_view/review/__init__.py +0 -63
  122. elspais/trace_view/review/branches.py +0 -1142
  123. elspais/trace_view/review/models.py +0 -1200
  124. elspais/trace_view/review/position.py +0 -591
  125. elspais/trace_view/review/server.py +0 -1032
  126. elspais/trace_view/review/status.py +0 -455
  127. elspais/trace_view/review/storage.py +0 -1343
  128. elspais/trace_view/scanning.py +0 -213
  129. elspais/trace_view/specs/README.md +0 -84
  130. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  131. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  132. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  133. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  134. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  135. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  136. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  137. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  138. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  139. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  140. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  141. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  142. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  143. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  144. elspais-0.11.2.dist-info/RECORD +0 -101
  145. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  146. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  147. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
@@ -1,591 +0,0 @@
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
- # Enums
21
- # REQ-tv-d00012-B: Confidence levels as string enums
22
- # =============================================================================
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
-
33
- EXACT = "exact"
34
- APPROXIMATE = "approximate"
35
- UNANCHORED = "unanchored"
36
-
37
-
38
- # =============================================================================
39
- # Data Classes
40
- # =============================================================================
41
-
42
-
43
- @dataclass
44
- class ResolvedPosition:
45
- """
46
- Result of resolving a CommentPosition against current REQ content.
47
-
48
- REQ-tv-d00012-A: Resolves CommentPosition anchors to current document coordinates.
49
- REQ-tv-d00012-I: Includes resolutionPath describing the fallback strategy used.
50
-
51
- Contains all information needed to display a comment at its resolved
52
- location, including confidence level and original position for reference.
53
- """
54
-
55
- type: str # Resolved position type (PositionType value)
56
- confidence: str # ResolutionConfidence value
57
- lineNumber: Optional[int] # Resolved line (1-based), None for general
58
- lineRange: Optional[Tuple[int, int]] # Resolved line range (start, end), 1-based
59
- charRange: Optional[Tuple[int, int]] # Character offsets in body (start, end), 0-based
60
- matchedText: Optional[str] # The text that was matched (for debugging)
61
- originalPosition: CommentPosition # Original position for reference
62
- resolutionPath: str # How position was resolved (for debugging)
63
-
64
- @classmethod
65
- def create_exact(
66
- cls,
67
- position_type: str,
68
- line_number: Optional[int],
69
- line_range: Optional[Tuple[int, int]],
70
- char_range: Optional[Tuple[int, int]],
71
- matched_text: Optional[str],
72
- original: CommentPosition,
73
- ) -> "ResolvedPosition":
74
- """
75
- Factory for exact resolution (hash matched).
76
-
77
- REQ-tv-d00012-C: Hash match yields EXACT confidence.
78
- """
79
- return cls(
80
- type=position_type,
81
- confidence=ResolutionConfidence.EXACT.value,
82
- lineNumber=line_number,
83
- lineRange=line_range,
84
- charRange=char_range,
85
- matchedText=matched_text,
86
- originalPosition=original,
87
- resolutionPath="hash_match",
88
- )
89
-
90
- @classmethod
91
- def create_approximate(
92
- cls,
93
- position_type: str,
94
- line_number: Optional[int],
95
- line_range: Optional[Tuple[int, int]],
96
- char_range: Optional[Tuple[int, int]],
97
- matched_text: Optional[str],
98
- original: CommentPosition,
99
- resolution_path: str,
100
- ) -> "ResolvedPosition":
101
- """
102
- Factory for approximate resolution (fallback succeeded).
103
-
104
- REQ-tv-d00012-D: Fallback resolution yields APPROXIMATE confidence.
105
- """
106
- return cls(
107
- type=position_type,
108
- confidence=ResolutionConfidence.APPROXIMATE.value,
109
- lineNumber=line_number,
110
- lineRange=line_range,
111
- charRange=char_range,
112
- matchedText=matched_text,
113
- originalPosition=original,
114
- resolutionPath=resolution_path,
115
- )
116
-
117
- @classmethod
118
- def create_unanchored(cls, original: CommentPosition) -> "ResolvedPosition":
119
- """
120
- Factory for unanchored resolution (all fallbacks failed).
121
-
122
- REQ-tv-d00012-J: When no fallback succeeds, resolve as UNANCHORED
123
- with original position preserved.
124
- """
125
- return cls(
126
- type=PositionType.GENERAL.value,
127
- confidence=ResolutionConfidence.UNANCHORED.value,
128
- lineNumber=None,
129
- lineRange=None,
130
- charRange=None,
131
- matchedText=None,
132
- originalPosition=original,
133
- resolutionPath="fallback_exhausted",
134
- )
135
-
136
- def validate(self) -> Tuple[bool, List[str]]:
137
- """Validate resolved position fields"""
138
- errors = []
139
-
140
- if self.type not in [pt.value for pt in PositionType]:
141
- errors.append(f"Invalid position type: {self.type}")
142
-
143
- if self.confidence not in [cl.value for cl in ResolutionConfidence]:
144
- errors.append(f"Invalid confidence level: {self.confidence}")
145
-
146
- # Line numbers must be positive if present
147
- if self.lineNumber is not None and self.lineNumber < 1:
148
- errors.append("lineNumber must be positive")
149
-
150
- # Line range validation
151
- if self.lineRange is not None:
152
- if len(self.lineRange) != 2:
153
- errors.append("lineRange must be tuple of (start, end)")
154
- elif self.lineRange[0] < 1 or self.lineRange[1] < self.lineRange[0]:
155
- errors.append("Invalid lineRange: start must be >= 1 and end >= start")
156
-
157
- # Char range validation
158
- if self.charRange is not None:
159
- if len(self.charRange) != 2:
160
- errors.append("charRange must be tuple of (start, end)")
161
- elif self.charRange[0] < 0 or self.charRange[1] < self.charRange[0]:
162
- errors.append("Invalid charRange: start must be >= 0 and end >= start")
163
-
164
- return len(errors) == 0, errors
165
-
166
- def to_dict(self) -> Dict[str, Any]:
167
- """Convert to JSON-serializable dictionary"""
168
- result: Dict[str, Any] = {
169
- "type": self.type,
170
- "confidence": self.confidence,
171
- "resolutionPath": self.resolutionPath,
172
- "originalPosition": self.originalPosition.to_dict(),
173
- }
174
- if self.lineNumber is not None:
175
- result["lineNumber"] = self.lineNumber
176
- if self.lineRange is not None:
177
- result["lineRange"] = list(self.lineRange)
178
- if self.charRange is not None:
179
- result["charRange"] = list(self.charRange)
180
- if self.matchedText is not None:
181
- result["matchedText"] = self.matchedText
182
- return result
183
-
184
- @classmethod
185
- def from_dict(cls, data: Dict[str, Any]) -> "ResolvedPosition":
186
- """Create from dictionary (JSON deserialization)"""
187
- line_range = data.get("lineRange")
188
- if line_range is not None:
189
- line_range = tuple(line_range)
190
- char_range = data.get("charRange")
191
- if char_range is not None:
192
- char_range = tuple(char_range)
193
- return cls(
194
- type=data["type"],
195
- confidence=data["confidence"],
196
- lineNumber=data.get("lineNumber"),
197
- lineRange=line_range,
198
- charRange=char_range,
199
- matchedText=data.get("matchedText"),
200
- originalPosition=CommentPosition.from_dict(data["originalPosition"]),
201
- resolutionPath=data.get("resolutionPath", "unknown"),
202
- )
203
-
204
-
205
- # =============================================================================
206
- # Helper Functions
207
- # =============================================================================
208
-
209
-
210
- def find_line_in_text(text: str, line_number: int) -> Optional[Tuple[int, int]]:
211
- """
212
- Find character range for a specific line in text.
213
-
214
- Args:
215
- text: The text to search in
216
- line_number: 1-based line number to find
217
-
218
- Returns:
219
- Tuple of (start_offset, end_offset) for the line, or None if line doesn't exist.
220
- end_offset points to the character after the line content (before newline or EOF).
221
- """
222
- if not text or line_number < 1:
223
- return None
224
-
225
- lines = text.split("\n")
226
-
227
- if line_number > len(lines):
228
- return None
229
-
230
- # Calculate start offset by summing lengths of previous lines + newlines
231
- start_offset = 0
232
- for i in range(line_number - 1):
233
- start_offset += len(lines[i]) + 1 # +1 for newline
234
-
235
- # End offset is start + length of this line
236
- end_offset = start_offset + len(lines[line_number - 1])
237
-
238
- return (start_offset, end_offset)
239
-
240
-
241
- def find_context_in_text(text: str, context: str) -> Optional[Tuple[int, int]]:
242
- """
243
- Find character range where context appears in text.
244
-
245
- Args:
246
- text: The text to search in
247
- context: The substring to find
248
-
249
- Returns:
250
- Tuple of (start_offset, end_offset) for first occurrence, or None if not found.
251
- """
252
- if not text or not context:
253
- return None
254
-
255
- index = text.find(context)
256
- if index == -1:
257
- return None
258
-
259
- return (index, index + len(context))
260
-
261
-
262
- def find_keyword_occurrence(text: str, keyword: str, occurrence: int) -> 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(text: str, start: int, end: int) -> Tuple[int, int]:
325
- """
326
- Convert character range to line range.
327
-
328
- Args:
329
- text: The text to analyze
330
- start: Start character offset (0-based, inclusive)
331
- end: End character offset (0-based, exclusive)
332
-
333
- Returns:
334
- Tuple of (start_line, end_line) as 1-based line numbers.
335
- """
336
- start_line = get_line_number_from_char_offset(text, start)
337
- # Handle empty range (start == end) - both on same line
338
- if start >= end:
339
- return (start_line, start_line)
340
- # end is exclusive, so use end-1 for the line calculation
341
- end_line = get_line_number_from_char_offset(text, end - 1)
342
-
343
- return (start_line, end_line)
344
-
345
-
346
- def get_total_lines(text: str) -> int:
347
- """
348
- Get total number of lines in text.
349
-
350
- Args:
351
- text: The text to analyze
352
-
353
- Returns:
354
- Total line count (minimum 1 for non-empty text, 0 for empty)
355
- """
356
- if not text:
357
- return 0
358
- return text.count("\n") + 1
359
-
360
-
361
- # =============================================================================
362
- # Core Resolution Functions
363
- # =============================================================================
364
-
365
-
366
- def resolve_position(
367
- position: CommentPosition, content: str, current_hash: str
368
- ) -> ResolvedPosition:
369
- """
370
- Resolve a comment position against current requirement content.
371
-
372
- REQ-tv-d00012-A: Resolves CommentPosition anchors to current document coordinates.
373
-
374
- This is the main entry point for position resolution. It determines
375
- the current location of a comment anchor, accounting for potential
376
- content drift since the comment was created.
377
-
378
- Args:
379
- position: The original CommentPosition from the comment/thread
380
- content: Current requirement body text
381
- current_hash: Current 8-character hash of the requirement
382
-
383
- Returns:
384
- ResolvedPosition with confidence level and resolved coordinates
385
-
386
- Resolution Strategy:
387
- 1. If hash matches: Return exact position based on original type
388
- 2. If hash differs, try fallbacks in order:
389
- a. lineNumber (if within valid range)
390
- b. fallbackContext (substring search)
391
- c. keyword at keywordOccurrence
392
- d. Fall back to general (unanchored)
393
- """
394
- # Handle empty text edge case
395
- if not content:
396
- return ResolvedPosition.create_unanchored(position)
397
-
398
- # REQ-tv-d00012-H: GENERAL positions always resolve with EXACT confidence
399
- if position.type == PositionType.GENERAL.value:
400
- return _resolve_general(position, content)
401
-
402
- # REQ-tv-d00012-C: Check if hash matches (exact resolution) - case insensitive
403
- hash_matches = position.hashWhenCreated.lower() == current_hash.lower()
404
-
405
- if hash_matches:
406
- return _resolve_exact(position, content)
407
- else:
408
- # REQ-tv-d00012-D: Hash differs, attempt fallback resolution
409
- return _resolve_with_fallback(position, content)
410
-
411
-
412
- def _resolve_general(position: CommentPosition, content: str) -> ResolvedPosition:
413
- """
414
- Resolve GENERAL position type.
415
-
416
- REQ-tv-d00012-H: GENERAL positions always resolve with EXACT confidence
417
- since they apply to the entire requirement.
418
- """
419
- total_lines = get_total_lines(content)
420
- return ResolvedPosition.create_exact(
421
- position_type=PositionType.GENERAL.value,
422
- line_number=None,
423
- line_range=(1, total_lines) if total_lines > 0 else None,
424
- char_range=(0, len(content)) if content else None,
425
- matched_text=None,
426
- original=position,
427
- )
428
-
429
-
430
- def _resolve_exact(position: CommentPosition, content: str) -> ResolvedPosition:
431
- """
432
- Resolve position when hash matches (exact confidence).
433
-
434
- REQ-tv-d00012-C: When the document hash matches, the position resolves
435
- with EXACT confidence using stored coordinates.
436
-
437
- Trusts the original position data since content hasn't changed.
438
- """
439
- pos_type = position.type
440
-
441
- if pos_type == PositionType.GENERAL.value:
442
- return _resolve_general(position, content)
443
-
444
- elif pos_type == PositionType.LINE.value:
445
- line_num = position.lineNumber
446
- char_range = find_line_in_text(content, line_num)
447
- matched_text = None
448
-
449
- if char_range:
450
- matched_text = content[char_range[0] : char_range[1]]
451
-
452
- return ResolvedPosition.create_exact(
453
- position_type=pos_type,
454
- line_number=line_num,
455
- line_range=(line_num, line_num) if line_num else None,
456
- char_range=char_range,
457
- matched_text=matched_text,
458
- original=position,
459
- )
460
-
461
- elif pos_type == PositionType.BLOCK.value:
462
- line_range = position.lineRange
463
- if line_range:
464
- start_range = find_line_in_text(content, line_range[0])
465
- end_range = find_line_in_text(content, line_range[1])
466
-
467
- char_range = None
468
- matched_text = None
469
- if start_range and end_range:
470
- char_range = (start_range[0], end_range[1])
471
- matched_text = content[char_range[0] : char_range[1]]
472
-
473
- return ResolvedPosition.create_exact(
474
- position_type=pos_type,
475
- line_number=line_range[0], # First line of block
476
- line_range=line_range,
477
- char_range=char_range,
478
- matched_text=matched_text,
479
- original=position,
480
- )
481
- else:
482
- return ResolvedPosition.create_unanchored(position)
483
-
484
- elif pos_type == PositionType.WORD.value:
485
- keyword = position.keyword
486
- occurrence = position.keywordOccurrence or 1
487
-
488
- if keyword:
489
- char_range = find_keyword_occurrence(content, keyword, occurrence)
490
-
491
- line_number = None
492
- line_range_result = None
493
- matched_text = keyword if char_range else None
494
-
495
- if char_range:
496
- line_number = get_line_number_from_char_offset(content, char_range[0])
497
- line_range_result = get_line_range_from_char_range(
498
- content, char_range[0], char_range[1]
499
- )
500
-
501
- return ResolvedPosition.create_exact(
502
- position_type=pos_type,
503
- line_number=line_number,
504
- line_range=line_range_result,
505
- char_range=char_range,
506
- matched_text=matched_text,
507
- original=position,
508
- )
509
- else:
510
- return ResolvedPosition.create_unanchored(position)
511
-
512
- # Unknown type - fall back to unanchored
513
- return ResolvedPosition.create_unanchored(position)
514
-
515
-
516
- def _resolve_with_fallback(position: CommentPosition, content: str) -> ResolvedPosition:
517
- """
518
- Resolve position when hash differs (approximate confidence).
519
-
520
- REQ-tv-d00012-D: When document hash differs, attempt fallback resolution.
521
- REQ-tv-d00012-E: For LINE positions, search for context string.
522
- REQ-tv-d00012-F: For BLOCK positions, search for context and expand.
523
- REQ-tv-d00012-G: For WORD positions, search for keyword at Nth occurrence.
524
-
525
- Tries fallback strategies in order:
526
- 1. lineNumber (if within valid range)
527
- 2. fallbackContext (substring search)
528
- 3. keyword at keywordOccurrence
529
- 4. Unanchored (general)
530
- """
531
- total_lines = get_total_lines(content)
532
-
533
- # Strategy 1: Try lineNumber if available and in range
534
- # For block positions, also try the first line of lineRange
535
- line_to_try = position.lineNumber
536
- if line_to_try is None and position.lineRange is not None:
537
- line_to_try = position.lineRange[0]
538
-
539
- if line_to_try is not None:
540
- if 1 <= line_to_try <= total_lines:
541
- char_range = find_line_in_text(content, line_to_try)
542
- if char_range:
543
- matched_text = content[char_range[0] : char_range[1]]
544
- return ResolvedPosition.create_approximate(
545
- position_type=PositionType.LINE.value,
546
- line_number=line_to_try,
547
- line_range=(line_to_try, line_to_try),
548
- char_range=char_range,
549
- matched_text=matched_text,
550
- original=position,
551
- resolution_path="fallback_line_number",
552
- )
553
-
554
- # Strategy 2: Try fallbackContext
555
- # REQ-tv-d00012-E: For LINE positions, search for context string
556
- if position.fallbackContext:
557
- char_range = find_context_in_text(content, position.fallbackContext)
558
- if char_range:
559
- line_number = get_line_number_from_char_offset(content, char_range[0])
560
- line_range = get_line_range_from_char_range(content, char_range[0], char_range[1])
561
- return ResolvedPosition.create_approximate(
562
- position_type=PositionType.LINE.value, # Resolved to line
563
- line_number=line_number,
564
- line_range=line_range,
565
- char_range=char_range,
566
- matched_text=position.fallbackContext,
567
- original=position,
568
- resolution_path="fallback_context",
569
- )
570
-
571
- # Strategy 3: Try keyword occurrence
572
- # REQ-tv-d00012-G: For WORD positions, search for keyword
573
- if position.keyword:
574
- occurrence = position.keywordOccurrence or 1
575
- char_range = find_keyword_occurrence(content, position.keyword, occurrence)
576
- if char_range:
577
- line_number = get_line_number_from_char_offset(content, char_range[0])
578
- line_range = get_line_range_from_char_range(content, char_range[0], char_range[1])
579
- return ResolvedPosition.create_approximate(
580
- position_type=PositionType.WORD.value,
581
- line_number=line_number,
582
- line_range=line_range,
583
- char_range=char_range,
584
- matched_text=position.keyword,
585
- original=position,
586
- resolution_path="fallback_keyword",
587
- )
588
-
589
- # Strategy 4: Fall back to general (unanchored)
590
- # REQ-tv-d00012-J: When no fallback succeeds, resolve as UNANCHORED
591
- return ResolvedPosition.create_unanchored(position)