Repository navigation
Per-language degradation when a tree-sitter grammar is unavailable (#117) - #143
Merged
Merged
Conversation
Additive status fields for per-language degradation. Nothing produces a degraded generation yet; a host with every grammar loadable reports coverage "complete" and behaves exactly as before. - grammar_cache: grammars_for_language() (typescript needs typescript, tsx and javascript), GrammarAvailability / snapshot_availability(), unavailable_languages(), GRAMMAR_DEGRADED; grammar_status() maps missing grammars through grammars_for_language() and adds repositories_degraded. - unavailable_grammar() now checks every grammar a language's plugin loads, fixing two pre-existing gaps: a missing tsx grammar no longer lets .tsx parse with the TypeScript grammar, and a missing javascript grammar fails .ts files as grammar_unavailable instead of an unrelated plugin error. IndexResult gains `language` for such failures. - sqlite_store: grammar_degradation index_config marker helpers and stored_paths_for_languages(). - readiness: RepositoryReadiness.coverage (state stays ready), read from the marker with a file-signature cache; to_dict() always has "coverage". - get_status health rows gain coverage / coverage_status; search_code switches to the envelope (coverage + note) only while degraded, and symbol_lookup not-found carries coverage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uilds (#117, 2/3) Staged full and incremental builds now publish the other languages instead of failing the whole repository when a tree-sitter grammar cannot load offline. - Availability is snapshotted once when the stage starts. index_directory(grammar_availability=...) skips files of an unavailable language before any plugin runs and defers (rather than fails) a file whose grammar disappears mid-build; the incremental path skips changed files of those languages the same way. - The whole degraded language is dropped from the generation: every stored row of it (copied forward, or chunkless after prepare_generation) is removed through dispatcher.remove_file so FTS rows and vectors go too. The grammar_degradation marker is counted from the committed inputs, so full and incremental builds record the same coverage (#111 parity). - If every admitted file is deferred the build still refuses to publish, now with code grammar_unavailable. - Scope: gateway/MCP path-scoped reindex (stage_operation), the watcher and the stdio background index stay fail-closed. - A degraded generation is never archived for upload or turned into a commit artifact. - A fully prefetched host gets no marker and calls the walk exactly as before. - SUPPORT_MATRIX grammar section and CHANGELOG (incl. the note that older binaries ignore the marker). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…117, 3/3) When the grammars of languages a published generation left out become loadable (e.g. after `mcp-index grammars prefetch`, no restart), index only those languages' files. - sync: before returning up_to_date, recoverable_degraded_languages() checks the published marker (cached; re-reads the grammar cache only for a degraded generation) and runs an empty-change-set incremental build. It skips _should_full_reindex, so other languages' rows, chunk ids and vectors are untouched; chunk_identity_scheme is unchanged. - The incremental branch adds every committed admitted file of a marker language that now loads, and the marker is recomputed (cleared when coverage is complete). - RefPoller: when the tip is unchanged, sync if coverage is recoverable. - Automatic repair starts only from a ready repository (no staleness reason), so a failed repair is not retried on every poll. - An unreadable marker is only replaced by a full rebuild. - SUPPORT_MATRIX and CHANGELOG describe the repair. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l and sweep - Restore refuses an artifact that carries a grammar_degradation marker (before any generation mutation), and refuses one that lacks a committed file this host would store (code artifact_incomplete), judged from the artifact's own file set against the admitted committed inputs (path sets only). An uploader older than #117 drops the unknown marker but archives the partial rows; those are no longer published as complete. A verified complete restore clears the marker the stage inherited from a degraded live generation. - "All files deferred" is decided from the admitted committed inputs: with a marker and no non-deferred indexable source left, the build refuses with grammar_unavailable even when retained history/document rows would keep the generation non-empty. - The watcher sweep skips files of languages the published marker left out, so they are not drift and never force a full rebuild; repair stays on the sync/ref-poller path. - Regression test: with in-process plugins (sandbox disabled) a missing javascript grammar still defers .ts and records it, and repair loads the plugin once the grammar arrives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…complete host (#117) On a host where every grammar loaded when a staged build started, a grammar that became unavailable partway through still failed the whole rebuild with grammar_unavailable, because the opt-in snapshot was only passed to the walk when it was incomplete. - The build's availability snapshot now reaches _full_index and _incremental_index_update through a per-thread scope held around the call, also when it is complete. A grammar lost mid-build therefore defers its language exactly like one missing at the start: every row of it (including files indexed before the loss) is dropped, the marker records it, the other languages publish, and the sync/ref-poller repair re-indexes it once the grammar loads. - The scope (rather than a new call keyword) keeps overrides and test doubles of those methods working unchanged. - index_directory adds deferred_files/degraded_languages only once a file is deferred when the snapshot is complete, so a prefetched host with no mid-build change gets the same stats as before. - Tests: grammar lost mid full rebuild, mid incremental build, and a regression that a prefetched host with no change mid-build is opted in yet indexes, reports and publishes exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…117) A committed rename in an incremental build still failed the whole rebuild when the language's grammar became unavailable before the staged move_file: move_file replaced the reindex's grammar_unavailable result with a generic "Renamed committed content was not reindexed" error, and the rename branch recorded it as a required-mutation failure. - Staged move_file returns the reindex's grammar_unavailable IndexResult (with its language) instead of the generic error. Non-staged moves (watcher, path-scoped) never reindex there and are unchanged. - The incremental rename branch defers that result like an added file, when the build is opted in; the moved chunkless row at the new path is dropped with the rest of the language before publish, and the old path's row no longer exists after the move. Without the opt-in the result still fails the build. - Tests: grammar lost just before a staged rename (fails without the fix with the reviewer's exact error), and a rename with grammars available that still moves and reindexes as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#117) A staged rename deferred because its grammar was lost mid-build left its `file_moves` row (lib.rs -> renamed.rs) in the published generation: SQLiteStore.move_file records the move before the reindex runs, and the degradation purge's remove_file does not touch move history. - SQLiteStore.move_file takes an optional `move_record` dict that receives the inserted row id; delete_file_move(id) removes exactly that row. - The staged move_file passes it (staged moves only) and, when the reindex returns grammar_unavailable, deletes that one record before returning the result. Moves with grammars available, non-staged moves and remove_file are unchanged. - The rename deferral test now asserts no matching file_moves row; the grammars-available rename test asserts the move is recorded once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ViperJuice
marked this pull request as ready for review
November 10, 2026 07:31
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Until now, one missing tree-sitter grammar failed the whole repository's staged rebuild, in every language. With this change, staged full and incremental builds leave only the affected language out of the generation, publish everything else, and report the gap per repository. A later sync puts the language back once its grammars load.
The work is split into three commits in the plan's order, so review can follow it:
feat(readiness)). This commit only adds fields and changes no indexing yet. It addsgrammars_for_language(), an availability snapshot, helpers for thegrammar_degradationmarker, readinesscoverage,coverage/coverage_statuson health rows,grammars.repositories_degraded, thesearch_codeenvelope while degraded, andcoverageonsymbol_lookupnot-found responses. It also fixes two bugs that were already there:tsxgrammar no longer lets.tsxfiles be silently parsed with the TypeScript grammar.javascriptgrammar now fails.tsfiles withgrammar_unavailable, instead of an unrelated error from the plugin constructor.feat(indexing)):dispatcher.remove_file, so its FTS rows and vectors go too. The marker is counted from the committed inputs, which keeps the full-walk/incremental parity rule from Incremental repository sync fails when a commit changes files in a disabled language (e.g. YAML) #111.grammar_unavailable.feat(indexing)). On sync (beforeup_to_date), and on a ref-poller tick where the tip has not moved,recoverable_degraded_languages()checks whether any left-out language's grammars now load. If so, it runs an incremental build with an empty change set that indexes only those languages' files. Automatic repair starts only from a cleanreadystate, so a repair that fails is not retried on every poll.Owner decisions (as implemented)
coveragesub-object is added alongside readiness. The repository readiness state staysready, and there is no new readiness state.dispatcher.remove_file.search_codeswitches to the envelope (results,query,coverage,note) only while coverage is degraded, following the indexing-in-progress envelope.symbol_lookupnot-found responses carrycoverage.reindexthroughrebuild_repository_index. These paths stay fail-closed:stage_operation_should_full_reindex, so other languages' chunk ids and vectors do not change.chunk_identity_schemeis not touched.Behaviour on a fully prefetched host
When every grammar loads, behaviour is unchanged:
index_directoryreturns the same stats keys as before;test_prefetched_host_unchanged_mid_build_behaves_as_before[full|incremental]covers this.coverage: {"state": "complete"}andsearch_codekeeps the bare result list.test_prefetched_host_has_no_markercovers this.A
LIBS_DIR_UNSAFEhost where every grammar loads also counts as complete coverage.Status / JSON fields added
RepositoryReadiness.to_dict()and every surface that embeds it):coverageis one of:nullwhen no index could be inspected{"state": "complete"}{"state": "degraded", "unavailable_languages": [...], "missing_grammars": [...], "deferred_files": n, "recoverable": bool, "remediation": "..."}, plus"unreadable": truefor a marker this binary cannot parseget_status:repositories[].coverageandrepositories[].coverage_statusgrammars.repositories_degraded: [repo_id, ...]search_code: while degraded, the envelope{results, query, coverage, note}replaces the bare list. A no-results response gainscoverageandnote.symbol_lookupnot-found:coverageandnote.IndexSyncResult):degraded_languagesreadiness.coverageon successcode: "grammar_unavailable"when every admitted file was deferredindex_configkeygrammar_degradation={"version": 1, "languages": {lang: {"grammars": [...], "deferred_files": n}}}.Docs
docs/SUPPORT_MATRIX.md: the grammar section now describes per-language degradation,coverage, repair, artifacts and scope.CHANGELOG.md[Unreleased]: the feature, the two bug fixes, and a rollback note: binaries older than this change ignore the marker and serve a degraded generation as complete until its next rebuild.Tests
Grammar availability is always faked through
grammar_cache.cached_grammars. The tests make no network calls and don't touch the pack.Commands:
uv run --extra dev pytest <files> -n 2 --no-cov, run on affected files only.git status --untracked-files=allis clean afterwards.New tests:
tests/test_grammar_registry.py:grammars_for_languagelibs_dir_unsafe→ complete coveragejavascript→.tsfails withgrammar_unavailable.ts/.tsxdeferredtests/test_git_index_manager.py:test_degraded_full_rebuild_publishes_other_languagestest_degraded_rebuild_purges_previous_rows_of_degraded_language[full|incremental]: checks files, symbols, chunks and FTStest_incremental_preserves_marker_and_unaffected_chunk_idstest_all_files_deferred_refuses_with_grammar_unavailabletest_prefetched_host_has_no_markertest_create_commit_artifact_refuses_degraded_generationtest_repair_after_grammar_arrival_indexes_only_deferred_files: the other files' chunk ids are byte-identical before and after, and_should_full_reindexis never consultedtest_no_automatic_repair_while_a_failed_sync_is_pendingtests/test_repository_readiness.py:test_coverage_degraded_keeps_state_readycoverage: nulltests/test_tool_handlers_readiness.py:test_search_envelope_carries_coverage_when_degraded[hits|no hits]symbol_lookupnot-found carriescoveragetest_get_status_health_row_coveragetests/test_artifact_upload.py:test_upload_refuses_degraded_generationtests/test_index_admission_parity.py:test_full_walk_and_incremental_agree_under_grammar_degradationtests/test_ref_poller.py: repair on an unchanged tip (recoverable / empty / non-set), and a failed coverage check never syncstests/test_health_surface.py: the expected health-row key set gainscoverageandcoverage_statusThe base is
origin/mainbd61265, after #139 merged; the rebase was clean.-n 2: 738 passed, 2 failed.test_index_admission_parity.py::test_failed_rebuilds_keep_only_the_newest_failed_generation.test_v13_data_storage.py::test_internal_cross_repo_coordinator_does_not_confuse_result_shapeshit the 20 s per-file lexical timeout on a two-linehello.py.full avg300≈2). I found no OOM record, so the cause is not verified.-p no:xdist: 2 passed).README.md(test_repeated_rebuilds_keep_live_plus_bounded_previous_generations). It passed 3 times in isolation, taking 7 s to 24 s.ruff,blackandisortare clean on the changed files.ruffstill reports three unused imports intests/test_health_surface.py; they are already onmainand were left alone.Review follow-up (f64c9d9)
These are fixes for the independent review's findings.
grammar_degradationmarker is refused, before any generation mutation.artifact_incomplete. The check compares the artifact's own file set against the admitted committed inputs, path sets only.javascriptgrammar: this did not reproduce. Admission resolvestypescriptfromPluginFactory.get_plugin_availability(), which reportsenabledwithout constructing the plugin, so.tsreaches the deferral check, gets deferred and is recorded. I added the requested sandbox-disabled regression test. It covers both deferral and repair after the grammar arrives, including when a plugin build failed while the grammar was missing.Tests:
-n 2: 714 passed, 0 failed. Files:test_git_index_manager,test_watcher_sweep,test_index_admission_parity,test_artifact_download,test_sweeper_observability,test_v13_data_reconciliation,test_v13_data_storage,test_v13_prep_panel_repairs,test_watcher_multi_repo,test_grammar_registry,test_repository_readiness,test_tool_handlers_readiness,test_ref_poller,test_artifact_upload.Review follow-up 2 (b69f219): grammar lost mid-build on a complete host
Earlier commits handled a grammar lost mid-build only when the build's snapshot was already incomplete; on a host that had every grammar at the start, the snapshot was not passed to the walk, so the loss still failed the whole rebuild with
grammar_unavailable. That skipped case is now handled:_full_indexand_incremental_index_updatethrough a per-thread scope held around the call (_staged_grammar_scope), also when it is complete. A grammar lost mid-build defers its language exactly as one missing at the start: every row of it is dropped (including files indexed before the loss), the marker records it, the other languages publish, and the existing sync/ref-poller repair re-indexes it once the grammar loads.test_git_index_manager.pytests whoseMagicMock(side_effect=...)doubles of_full_indexreject unknown keywords. With the scope, no existing test was changed.index_directoryaddsdeferred_files/degraded_languagesonly once a file is deferred when the snapshot is complete, so a prefetched host's walk stats keep their pre-Per-language degradation when a tree-sitter grammar is unavailable #117 shape.docs/SUPPORT_MATRIX.mdsays a grammar lost partway through a build is handled like one missing at the start.New tests in
tests/test_git_index_manager.py, each failing with the fix removed:test_grammar_lost_mid_full_rebuild_defers_language: snapshot complete, rust lost after the first.rsfile indexes. Rust is deferred and recorded (deferred_files: 2), no rust file/symbol/chunk/FTS row is published, python and markdown publish, and repair restores rust. Without the fix:failedwithgrammar_unavailable.test_grammar_lost_mid_incremental_build_defers_language: same loss during an incremental build; the python change publishes and unchanged files keep their chunk ids. Without the fix:failed.test_prefetched_host_unchanged_mid_build_behaves_as_before[full|incremental]: the walk is opted in with a complete snapshot, yet nothing is deferred, stats carry no deferral keys, no marker is written, coverage iscompleteand chunk ids are unchanged. Without the fix it fails the opt-in assertion; with only the dispatcher change reverted it fails the stats-shape assertion.Tests: the same 14 affected files with
-n 2: 718 passed, 0 failed (714 before plus the 4 new).ruff,blackandisortare clean on the changed files.Review follow-up 3 (205817a): staged rename when a grammar is lost mid-build
A committed rename (
lib.rs -> renamed.rs) in an incremental build still failed the whole rebuild when the grammar disappeared just before the stagedmove_file. The stagedmove_filereplaced the reindex'sgrammar_unavailableresult with "Renamed committed content was not reindexed", and the rename branch recorded that as a required-mutation failure.move_filenow returns the reindex'sgrammar_unavailableIndexResult, with its language, instead of the generic error. I chose this over turning renames into remove-plus-add because it is smaller and leaves the rename path's existing semantics unchanged when grammars load.move_file, so they are unchanged.New tests in
tests/test_git_index_manager.py:test_grammar_lost_before_staged_rename_defers_language: rust is deferred and recorded (deferred_files: 1), and neitherlib.rsnorrenamed.rshas a file, symbol, chunk or FTS row. The python change publishes, other chunk ids are unchanged, and repair indexesrenamed.rs. Without the fix it fails with the reviewer's exact error ("Failed to move lib.rs -> renamed.rs: Renamed committed content was not reindexed").test_staged_rename_with_grammars_available_behaves_as_before: the same rename with every grammar available is still aMOVEDstaged move.renamed.rsis reindexed with chunks, symbols and FTS rows, there is no marker and coverage iscomplete. This test passes with or without the fix: it guards the unchanged path.Tests:
-n 2: 720 passed, 0 failed.move_file-related files with-n 2: 405 passed, 0 failed. Files:test_v13_data_vectors,test_dispatcher,test_rename_atomicity,test_watcher,test_two_phase_commit,test_dispatcher_advanced,test_reindex_resume,test_incremental_indexer.Review follow-up 4 (258f6d3):
file_movesrecord of a deferred staged renameA deferred staged rename left its
file_movesrow (lib.rs -> renamed.rs) in the published generation.SQLiteStore.move_filerecords the move before the reindex runs, and the degradation purge'sremove_filedoes not touch move history.Fix. The exact move record is now dropped when the rename is deferred:
SQLiteStore.move_filetakes an optionalmove_recorddict, which receives the inserted row id.delete_file_move(id)deletes exactly that one row.move_filepassesmove_record(staged moves only). When the reindex returnsgrammar_unavailable, it deletes that record before returning the result.Unchanged paths:
remove_file, which still never deletes move historyWhy this option, not recording the move only after the reindex succeeds. That alternative would reorder the SQLite primary operation inside
two_phase_commit, whose rollback and semantic shadow operation rely on the move and its record being one step. Dropping the one known record is local to the deferred branch, and leaves the successful path unchanged.Tests:
test_grammar_lost_before_staged_rename_defers_languagenow also asserts that the published generation has nolib.rs -> renamed.rsrow infile_moves. Without the fix it fails:assert ('lib.rs', 'renamed.rs') not in [('lib.rs', 'renamed.rs')].test_staged_rename_with_grammars_available_behaves_as_beforeasserts the move is still recorded exactly once.-n 2: 720 passed, 0 failed.move_file/store-related files with-n 2: 506 passed, 0 failed. Files: the eight from follow-up 3 plustest_sqlite_store,test_collection_provenanceandtest_chunker_scheme_recovery.Out of scope here: a Git type change (
Tstatus) publishing stale rows is tracked separately.Issue coverage
Fixes #117. Here is how this PR meets the issue's three acceptance criteria:
get_status; it is never silently chunkless.docs/SUPPORT_MATRIX.mddescribes the degraded state.The issue's automatic re-index after the grammar becomes available is covered by commit 3.
🤖 Generated with Claude Code
Open in [code]smith · Need help on this PR? Tag
@codesmith-botwith what you need.