jarvis: Code Standards & Conventions
September 15, 2026 · View on GitHub
This document describes the patterns and conventions actually observed in the jarvis codebase, derived from the implementation and architectural decisions made across all 4 phases.
Architecture & Design Patterns
1. Dataclass-First Type System
Pattern: All result types (nav/search results) are immutable frozen dataclasses, not Pydantic models.
Why:
- jarvis has no HTTP API boundary that would benefit from Pydantic validation
- MCP tool functions return dicts built directly from dataclasses via
dataclasses.asdict() - Minimal validation overhead; focus on correctness at the source (database query builder)
Examples:
@dataclass(frozen=True)
class Position:
line: int
character: int
@dataclass(frozen=True)
class SymbolInfo:
symbol: str
displayName: str | None = None
kind: str | None = None
Convention: Use | None union syntax (Python 3.10+) instead of Optional[T]. All result dataclasses are frozen.
2. Isolation Seams: Protobuf & zstandard
Pattern: scip_decoder.py is the single module importing scip_pb2 and zstandard.
Why:
- Future SCIP proto version bumps are localized to one file
- Tests can mock zstandard decompression without touching other modules
- Easier to track upstream API drift (scip.proto changes in sourcegraph/scip)
Example isolation:
# scip_decoder.py (only imports scip_pb2)
import zstandard
from jarvis.scip_pb2 import Document
# query.py, search.py (NO scip_pb2 imports; delegate to scip_decoder)
from jarvis.scip_decoder import scip_range_to_positions
Convention: Isolation seams are explicitly commented in module docstrings.
3. Atomic Pointer-Swap Publishing
Pattern: Index publishing writes a new versioned .db, waits for graph/Zoekt completion, then atomically swaps the current pointer file via os.replace().
Why:
- Queries reading the old index never see partial state
- A failure anywhere in the pipeline leaves the previously published index live
- Zero query downtime across reindex
Implementation (index_cli.py):
# Write new index to temp file
new_db_path = index_dir / f"index-{commit_sha}.db"
# Run all expensive operations
populate_graph_for_repo(repo_slug, symbols)
zoekt_index(new_db_path)
# Only once all succeed, atomically swap
current_pointer = index_dir / "current"
os.replace(new_db_path, current_pointer)
Convention: Pointer files are small text files (1 line: the commit SHA or db name). Never write a pointer until all expensive operations complete.
4. Rebuild-Not-Accumulate Graph
Pattern: Each reindex clears that repo's outgoing package dependencies before recomputing them.
Why:
- Removed dependencies are properly retracted (not stale in the database)
blastRadiusalways reflects each repo's last index run- No accumulation bugs from partial re-runs or tool failures
Implementation (graph.py):
def populate_graph_for_repo(repo_slug: str, symbols: list[str]) -> None:
# DELETE old edges for this repo
db.execute("DELETE FROM edges WHERE source_repo = ?", (repo_slug,))
# INSERT new edges
for pkg_name in extract_package_names(symbols):
db.execute("INSERT INTO edges ...", (repo_slug, pkg_name, ...))
Convention: Always clear before rebuild in batch operations. Document the rebuild expectation in docstrings.
5. Direct sqlite3 Usage (No ORM)
Pattern: Raw SQL executed via stdlib sqlite3 module; no SQLAlchemy, Tortoise, or GRDB.
Why:
- jarvis's schema is small (5-6 tables max):
repos,packages,edges(registry.db);documents,chunks,global_symbols,mentions,defn_enclosing_ranges(index-*.db) - SCIP schema is output of
scip expt-convert— not a designed API, treated as moving target - Zero external database dependency; easier to embed in single-user tool
Exceptions:
index_reader.py(vendored from SCIP source) does use sqlite3 with some optimization flags (mode=ro&immutable=1)
Convention: Parameterized queries always; no string interpolation for user input (e.g., repo slugs).
6. Broad Exception Handling in MCP Server
Pattern: server.py catches all exceptions at the MCP tool boundary and returns uniform error payload: {"error": "...message..."}.
Why:
- Keeps the stdio server alive even on query bugs
- Consistent error format for MCP clients
- Separates internal exception details from user-facing messages
Implementation (server.py):
@mcp.tool()
def goToDefinition(repo: str, path: str, line: int, character: int):
try:
result = _service().go_to_definition(repo, path, line, character)
return {"location": asdict(result)} if result else {"location": None}
except Exception as e:
return {"error": str(e)}
Convention: No bare except without rationale. Broad catches are documented with "by design" comments.
7. Environment Variable Overrides
Pattern: Configuration via env vars with sensible defaults.
Current overrides:
JARVIS_DATA_DIR→ override~/.jarvis(default)- Future:
JARVIS_ZOEKT_BIN→ override zoekt-webserver binary path
Convention: Prefix all env vars with JARVIS_. Use .expanduser() for path variables. Document in README and config.py docstring.
8. Base Grammar Dependencies and Optional Extras
Pattern: The Tree-sitter runtime and every curated grammar package are
base dependencies (pyproject.toml [project.dependencies], spec TSI-02);
heavier, truly optional capabilities stay gated behind
[project.optional-dependencies]. Two extras exist today:
watch = ["watchdog>=4.0"]— needed forjarvis watchin a source checkout; the standalone build bundles itsemantic = ["lancedb>=0.20", "sentence-transformers>=3.0"]— needed for source-buildsemanticSearch; the Homebrew distribution deliberately excludes it
(The grammar packages moved from the old tree-sitter-language-pack-based
semantic extra to base dependencies: the syntax baseline must parse offline
from prebuilt abi3 wheels with no extra installed and no download at index
time.)
Why:
- A base install has no ML/native-binding dependencies beyond the grammar wheels
- Every import of an extra's packages is deferred inside the function that needs it, never at
module top-level — so a base install can still import every module in
src/jarvis/without the extra installed - Missing an extra fails narrowly and legibly at the point of use (e.g.
semantic.pyraisesSemanticExtraMissingError, whichindex_cli.py's semantic stage catches and skips with a one-line hint); by contrast, a missing base grammar raisesSyntaxDependencyErrorimmediately and loudly, because a broken base install is a packaging bug, not an optional capability
Convention: Install extras with uv sync --extra <name>. New optional capabilities should follow
this same shape: add the extra, defer its imports, and fail with a specific, catchable exception
when it's missing.
9. Release-Version Guard Pattern
Pattern: pyproject.toml [project].version is the sole local release-version declaration. A dedicated script (scripts/check_versions.py) verifies that declaration is readable; it runs via an automated test (tests/test_check_versions.py) and CI. A release additionally runs uv lock so the lockfile's self-referential project entry follows the bump.
Why:
- Duplicating a version across descriptors invites partial bumps and an unusable release
- Homebrew archives, their checksums, and the generated formula are all keyed by one version, so the source of that value must be unambiguous
- Test gates all CI pipelines — an unreadable version fails the release
Implementation: check_versions.py parses pyproject.toml and returns the
single declared release version. It does not inspect plugin manifests in
jarvis-intelligence/jarvis-index, which version independently. The test wraps
the script and runs on every CI push/PR.
Convention: Do not add another local release-version declaration. Keep
uv.lock generated by uv lock, never hand-edited.
11. Single-Tenant Hardcoding
Pattern: PROJECT = "_" and BRANCH = "_" are pinned constants in config.py; the vendored IndexConnectionCache keys on (project, repo, branch), but jarvis uses only repo.
Why:
- Single user, one repo per slug
- Reuse vendored cache code without modification
- Disk path
scip/_/<slug>/_/is an artifact of the cache's path shape
Convention: These constants are intentionally hardcoded and not configurable. Document clearly in config.py docstring if ever tempted to make them dynamic.
12. Model-Identity-Locked Vector Store
Pattern: A SemanticStore LanceDB table (semantic.py) only ever holds vectors from one
TableIdentity at a time — model name, model revision, query prefix, doc prefix, and
CONTENT_FORMAT (chunker.py's version of the stored chunk-text shape) — recorded in the table
itself (table_identity()), not inferred from current config.
Why:
- Embedding spaces from different models (or model revisions) are not comparable — mixing them silently would rank results by meaningless distances
- A prefix change or a
CONTENT_FORMATbump changes what was actually embedded just as much as a model change does — folding both into the identity means a table is only reused when the whole identity matches, so a file whose bytes never changed can't carry header-less/wrong-prefix rows forward forever (carry-forward keys onfile_hash, notcontent_hash) index_semantic()always fully re-embeds every chunk when any part of the identity changes; the old table's vectors are never reusedsemantic_search()embeds the query using the table's recorded identity (model, revision, and prefixes — never the currently configured ones), and includes a"warning"in results if that differs from the currently configured identity — nudging a reindex instead of silently returning wrong-space results
Convention: Never compare or merge vectors across table identities. Any change to the default
embedding model, its prefixes, or CONTENT_FORMAT is a data-migration event (full reindex), not a
config tweak.
13. Curated Grammar Provider
Pattern: syntax.py owns the one finite map from internal language name to
grammar source — FACTORIES: dict[str, tuple[str, str, str]], mapping 17
language names to 17 pinned (distribution, module, factory) triples over 16
distributions (TypeScript and TSX are two factories from one distribution; PHP
selects the PHP-with-tags factory). Repository-supplied grammar code is never
instantiated.
Why:
- A finite, reviewed map is auditable: no grab-bag provider can silently resolve a language to an unexpected grammar or fetch one at runtime
- Grammar identity is pinned per distribution, so stored rows can be keyed on
a
grammar_identity()digest (extractor version + runtime version + grammar versions) and re-extracted exactly when the extractor changes
Conventions:
- Lazy imports:
tree_sittertypes areTYPE_CHECKING-only; grammar modules load insideParserPoolon first use per worker, never at module import - Frozen dataclasses for all extracted results (
Span,SyntaxSymbol,ParsedSyntax) — same rule as every other result type in the codebase - Byte-span slicing: node offsets are byte offsets into the captured UTF-8
bytes, and all text leaves the module through
slice_text(source, start, end)— neverstrindexing, which would corrupt multibyte positions - Extraction walks the tree with an explicit stack of sibling-iteration frames, never Python recursion — a deeply nested file cannot exhaust the interpreter's recursion limit
14. Staged Publication
Pattern: index_repo() runs fixed stages — validate, syntax baseline,
optional SCIP, Zoekt, optional semantic, revalidate + graph, then
publish → record → retire in that strict order — and each stage owns its
failure boundary. Expected SCIP-stage failures degrade (exit-0 degraded,
cause recorded); Zoekt/storage/publication failures fail the run with nothing
new published.
Why:
- The optional enrichment can never block the build-free baseline (spec TSI-01)
- Ordering publish before record before cleanup means a crash at any point leaves either the old or the new snapshot live — never an orphaned cleanup or a destroyed live snapshot
- One immutable
index-<sha>-<generation>.dbper run, selected by onecurrentpointer, keeps readers generation-consistent: a single tool operation cannot mix tables from two generations
Convention: Scratch build → unique final name (uuid4().hex generation)
→ write-temp-then-rename pointer flip → registry terminal write → retire
superseded snapshots (best-effort, warns only). Never mutate a published
database; never derive cleanup targets from anything but the filenames being
deleted.
15. Registry Status Vocabulary
Pattern: Status strings are module-level constants in registry.py
(INDEXING_STATUS, PARTIAL_STATUS, DEGRADED_STATUS, plus the SCIP_STATES
frozenset), not inline literals. The CLI writer and the MCP reader import the
same constants, so the vocabularies cannot drift.
Why:
jarvis statusoutput andgetIndexStatuspayloads must spell states identically; duplicated string literals drifted exactly this way before- A frozen vocabulary makes read-time normalization honest: an unrecognized
persisted
scip_statereads back asunknowninstead of propagating
Convention: Add a new state by extending the constant in registry.py
first, then the readers — never by writing a new literal at a call site.
Code Organization
Module Docstrings
Every module has a docstring explaining its purpose and key exports. Example:
"""SCIP blob decoder (zstd+protobuf); isolation seam for protobuf dependency.
Decode zstd+protobuf `scip.Document` occurrences and `global_symbols.relationships`.
Also parses symbol packages and range→position conversions.
Known gap: SCIP v0.9.0 converter never populates `relationships`,
so `typeHierarchy` is empty on real indexes (upstream issue scip-code/scip#464).
"""
Convention: Module docstrings should state purpose, key functions, and known gaps/limitations.
Single-Purpose Functions
Pattern: Small, testable functions with clear contracts.
Examples:
repo_slug(name: str) -> str— normalize user input, reject traversal attacksscip_range_to_positions(range) -> (line, character)— convert SCIP to LSP coordinatesshould_ignore_path(path) -> bool— centralized exclusion listhash_file(data: bytes) -> str(chunker.py) — content hash used for chunk dedup and carry-over-unchanged-files detectionlanguage_for(path: Path) -> str | None(chunker.py) — maps a file extension to its tree-sitter grammar, orNoneto fall back to fixed-window chunking
Convention: No "god functions" combining multiple concerns. If a function grows beyond ~50 lines, consider splitting.
CLI Design
Command Structure
All commands are under jarvis:
jarvis index <path> [--slug name] [--scheme name] [--language name] [--semantic-include path] [--scip | --no-scip]
jarvis list
jarvis status <slug>
jarvis reindex <slug> [--scip | --no-scip]
jarvis install-semantic
jarvis forget <slug>
jarvis watch <path> [--slug name] [--scheme name] [--language name] [--semantic-include path] [--debounce 5] [--scip | --no-scip]
jarvis install-semantic is source-only; it runs the canonical uv extras sync and refuses in the frozen Homebrew distribution.
Error Handling
Pattern: CLI errors print to stderr with context (which repo failed, why) and exit non-zero.
Examples:
f"Repo {repo} not indexed"f"Symbol not found in {path} at {line}:{character}"f"Failed to populate graph for {repo}: {e}"
Convention: Error messages are user-facing (appear in MCP responses). Make them actionable.
Testing Conventions
Test File Organization
Each test file mirrors its source module:
test_query.py→query.pytest_graph.py→graph.pytest_chunker.py→chunker.pytest_embeddings.py→embeddings.pytest_semantic.py→semantic.py- etc.
(models.py and __init__.py are the only modules without a dedicated test file — see Test Coverage below.)
Unit vs Integration Tests
Unit tests:
- Mock external dependencies (file I/O, subprocess calls, external HTTP)
- Use fixtures for synthetic SCIP blobs and SQLite schemas
- Mark with no special marker (run by default)
Integration tests:
- Call real binaries (scip-python, scip, zoekt-index, zoekt-webserver)
- Marked
@pytest.mark.integration - Concentrated in
test_index_cli.py(the full pipeline)
Run tests:
uv run pytest # all tests
uv run pytest -m "not integration" # unit only
uv run pytest -m integration # real binaries only
Convention: Mark integration tests explicitly. Don't surprise developers with subprocess calls in unit tests.
Fixtures
Location: tests/fixtures/
Key fixtures:
mini_py_repo/greeter.py— Minimal Python file for scip-python indexing testsscip_encoder.py— Real zstd+protobuf SCIP blob builders (deterministic, repeatable)synthetic_index.py— Hand-copied real SQLite schema (documents/chunks/global_symbols tables with sample data)
Convention: Fixtures contain real, reproducible data (not random). Blob fixtures are compressed and can be inspected with zstandard tools.
CLI Design
Command Structure
All commands are under jarvis:
jarvis index <path> [--slug name] [--scheme name] [--language name] [--semantic-include path]
jarvis list
jarvis status <slug>
jarvis reindex <slug>
jarvis install-semantic
jarvis forget <slug>
jarvis watch <path> [--slug name] [--scheme name] [--language name] [--semantic-include path] [--debounce 5]
Error Handling
CLI errors are printed to stderr with context (e.g., which repo failed, why).
try:
index_repo(path, slug)
except Exception as e:
print(f"Error indexing {path}: {e}", file=sys.stderr)
sys.exit(1)
Convention: Always exit with non-zero code on error. Include the repo/path in the error message.
Documentation Standards
Module Docstrings
Every module has a docstring (see above). Include:
- Purpose (1 sentence)
- Key exports / responsibilities
- Known gaps (upstream limitations, not implemented features)
Function Docstrings
Functions with non-obvious behavior have docstrings:
def repo_slug(name: str) -> str:
"""Normalize a user-chosen repo name into a directory-safe slug.
Rejects `.`/`..` explicitly (not just `/`) — both survive the
character-class substitution below unchanged since `.` is an allowed
slug character, but either one alone is a path-traversal component.
"""
Convention: Use present tense ("normalizes", "validates"). Include edge cases (like . / .. rejection).
Comments
Comments explain why, not what. Code is readable; comments should justify decisions.
Example (good):
# We use mode=ro&immutable=1 to enable WAL safety on NFS
# (vendored from source project's index_reader.py)
conn = sqlite3.connect(path, uri=True)
Example (bad):
# Open the database
conn = sqlite3.connect(path, uri=True)
Naming Conventions
Modules
- Lowercase, snake_case:
index_reader.py,scip_decoder.py
Classes & Dataclasses
- PascalCase:
QueryService,SymbolInfo,IndexConnectionCache
Functions & Methods
- snake_case:
repo_slug(),index_repo(),blast_radius()
Constants
- UPPER_CASE:
PROJECT,BRANCH,DEFAULT_DATA_DIR
Private Functions & Attributes
- Prefix with
_:_service(),_query_service,_json_safe()
Environment Variables
- UPPER_CASE, prefixed with
JARVIS_:JARVIS_DATA_DIR,JARVIS_ZOEKT_BIN,JARVIS_EMBEDDING_MODEL,JARVIS_EMBEDDING_BATCH_SIZE,JARVIS_EMBEDDING_QUERY_PREFIX,JARVIS_EMBEDDING_DOC_PREFIX
Performance & Scalability
Single-Threaded Query Path
The entire query path (from MCP tool → SQL → result) is synchronous and single-threaded. MCP clients are responsible for parallelization.
Convention: Don't add async/await unless blocking I/O becomes a bottleneck. jarvis is a single-user tool; no need for concurrent client handling.
Connection Pooling
IndexConnectionCache maintains a bounded pool of SQLite connections per unique (project, repo, branch, pointer_content) tuple, garbage-collected on pointer changes.
Convention: Reuse the cache for all queries; never open raw sqlite3 connections in query logic.
Type Hints
Convention: Use modern syntax (Python 3.10+):
str | Noneinstead ofOptional[str]list[T]instead ofList[T]dict[K, V]instead ofDict[K, V]
Full type hints on all public functions; private/internal functions may omit hints if obvious from context.
Future-Proofing
SCIP Version Pinning
SCIP proto is pinned to v0.9.0 in scip_pb2.py (regenerated from v0.7.0 because v0.7.0 lacked the typed_range oneof that scip-swift requires).
Convention: Document SCIP version in README and code. Any future version bump should be tracked in a plan, not a surprise refactor.
SQLite Schema Versioning
The scip expt-convert output schema is not versioned. If scip releases change the schema, jarvis will need to adapt query logic.
Convention: Document which scip release was tested. Add comments to SQL queries if they depend on specific schema columns.
Zoekt Server Lifespan
ZoektLifecycle manages the zoekt-webserver process (lazy-start, pidfile-tracked, killed on exit).
Convention: The lifecycle is encapsulated in ZoektLifecycle; don't spawn zoekt-webserver elsewhere. Always check if it's running before querying.