Skip to content

Commit 6f77ff1

Browse files
Merge pull request #82 from GoodbyePlanet/fix/parser-error-purges-index-68
fix: preserve index entries when a parser fails instead of purging them
2 parents a87d3b9 + 243bc61 commit 6f77ff1

5 files changed

Lines changed: 78 additions & 10 deletions

File tree

server/indexer/pipeline.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from server.embeddings.bm25 import BM25SparseProvider, get_sparse_embedding_provider
1616
from server.embeddings import get_embedding_provider
1717
from server.indexer.github_source import fetch_blob_content, list_github_files
18-
from server.parser.base import CodeSymbol
18+
from server.parser.base import CodeSymbol, ParseError
1919
from server.parser.registry import parse_file
2020
from server.store.qdrant import QdrantStore
2121

@@ -209,7 +209,16 @@ async def index_service(
209209
logger.error("Failed to fetch %s: %s", stored_path, exc)
210210
continue
211211

212-
symbols = parse_file(content, stored_path)
212+
try:
213+
symbols = parse_file(content, stored_path)
214+
except ParseError:
215+
logger.error(
216+
"Skipping index update for %s: parser failed, "
217+
"existing entries preserved",
218+
stored_path,
219+
)
220+
continue
221+
213222
if not symbols:
214223
# File has no indexable symbols; clean up any stale entries.
215224
await self._store.delete_by_file(svc.name, stored_path)

server/parser/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ def _node_text(node: Node, source: bytes) -> str:
1010
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
1111

1212

13+
class ParseError(Exception):
14+
"""Raised when a language parser fails on a file's content."""
15+
16+
1317
@dataclass
1418
class CodeSymbol:
1519
name: str

server/parser/registry.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44

5-
from server.parser.base import CodeSymbol, LanguageParser
5+
from server.parser.base import CodeSymbol, LanguageParser, ParseError
66
from server.parser.bash import BashParser
77
from server.parser.c import CParser
88
from server.parser.compose import ComposeParser
@@ -109,6 +109,6 @@ def parse_file(source: bytes, file_path: str) -> list[CodeSymbol]:
109109
return []
110110
try:
111111
return parser.parse_file(source, file_path)
112-
except Exception:
112+
except Exception as exc:
113113
logger.exception("Parser %s failed for %s", type(parser).__name__, file_path)
114-
return []
114+
raise ParseError(file_path) from exc

tests/parser/test_registry.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
from server.parser import registry
6+
from server.parser.base import ParseError
67
from server.parser.bash import BashParser
78
from server.parser.c import CParser
89
from server.parser.compose import ComposeParser
@@ -99,7 +100,7 @@ def test_parse_file_returns_empty_for_unknown_extension() -> None:
99100
assert registry.parse_file(b"whatever", "svc/data.bin") == []
100101

101102

102-
def test_parse_file_swallows_parser_exceptions(monkeypatch) -> None:
103+
def test_parse_file_raises_parse_error_for_parser_exceptions(monkeypatch) -> None:
103104
class BoomParser:
104105
def parse_file(self, source, file_path):
105106
raise RuntimeError("boom")
@@ -110,7 +111,8 @@ def supported_extensions(self):
110111
monkeypatch.setattr(registry, "_PARSERS", {".boom": BoomParser()})
111112
monkeypatch.setattr(registry, "_FILENAME_PARSERS", {})
112113

113-
assert registry.parse_file(b"x", "svc/file.boom") == []
114+
with pytest.raises(ParseError):
115+
registry.parse_file(b"x", "svc/file.boom")
114116

115117

116118
def test_parse_file_dispatches_to_correct_parser(read_fixture) -> None:

tests/test_pipeline.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,36 @@
11
from __future__ import annotations
22

33
import logging
4-
5-
from server.indexer.pipeline import _build_bm25_text, _build_embedding_text
6-
from server.parser.base import CodeSymbol
4+
from unittest.mock import AsyncMock, patch
5+
6+
import server.indexer.pipeline as pipeline_module
7+
from server.config import ServiceConfig
8+
from server.indexer.github_source import GitHubFile
9+
from server.indexer.pipeline import (
10+
IndexPipeline,
11+
_build_bm25_text,
12+
_build_embedding_text,
13+
)
14+
from server.parser.base import CodeSymbol, ParseError
715

816
_TRUNCATION_MARKER = "// ... (truncated)"
917

1018

19+
class _StubEmbedder:
20+
dimensions = 1
21+
22+
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
23+
return [[0.0]] * len(texts)
24+
25+
26+
def _make_pipeline(store) -> IndexPipeline:
27+
pipeline = IndexPipeline.__new__(IndexPipeline)
28+
pipeline._store = store
29+
pipeline._embedder = _StubEmbedder()
30+
pipeline._sparse_embedder = _StubEmbedder()
31+
return pipeline
32+
33+
1134
def _sym_with_source(source: str, docstring: str = "") -> CodeSymbol:
1235
return CodeSymbol(
1336
name="fn",
@@ -127,3 +150,33 @@ def test_max_chars_is_configurable() -> None:
127150
sym = _sym_with_source("z" * 5_000)
128151
assert _TRUNCATION_MARKER not in _build_embedding_text(sym, "svc", max_chars=6000)
129152
assert _TRUNCATION_MARKER in _build_embedding_text(sym, "svc", max_chars=1000)
153+
154+
155+
async def test_parser_failure_preserves_existing_index_entries() -> None:
156+
svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[])
157+
github_file = GitHubFile(rel_path="broken.py", blob_sha="sha1")
158+
159+
store = AsyncMock()
160+
store.get_indexed_file_hashes.return_value = {"svc/broken.py": "old-sha"}
161+
162+
pipeline = _make_pipeline(store)
163+
164+
with (
165+
patch.object(
166+
type(pipeline_module.settings), "load_services", return_value=[svc]
167+
),
168+
patch.object(
169+
pipeline_module, "list_github_files", AsyncMock(return_value=[github_file])
170+
),
171+
patch.object(
172+
pipeline_module, "fetch_blob_content", AsyncMock(return_value=b"garbage")
173+
),
174+
patch.object(
175+
pipeline_module, "parse_file", side_effect=ParseError("svc/broken.py")
176+
),
177+
):
178+
result = await pipeline.index_service("svc", force=True)
179+
180+
store.delete_by_file.assert_not_called()
181+
store.upsert_chunks.assert_not_called()
182+
assert result["files"] == 0

0 commit comments

Comments
 (0)