Files
aditya 4e64544aae
CI / benchmark-publish (pull_request) Has been skipped
CI / quality (pull_request) Successful in 48s
CI / build (pull_request) Successful in 31s
CI / helm (pull_request) Successful in 21s
CI / lint (pull_request) Successful in 3m15s
CI / typecheck (pull_request) Successful in 4m4s
CI / security (pull_request) Successful in 4m13s
CI / unit_tests (pull_request) Successful in 4m28s
CI / docker (pull_request) Successful in 1m52s
CI / integration_tests (pull_request) Successful in 7m14s
CI / coverage (pull_request) Successful in 12m49s
CI / e2e_tests (pull_request) Successful in 22m8s
CI / status-check (pull_request) Successful in 2s
CI / benchmark-regression (pull_request) Successful in 57m27s
feat(acms): projects with 10,000+ files index without timeout
Add explicit indexing timeout bounds and propagate timeout_seconds through the repo indexing service, utility walker, and CLI entrypoint so large repositories fail predictably instead of hanging.

Add 10K-scale Behave/Robot verification and a dedicated benchmark path to validate that large-project indexing completes, persists status correctly, and remains measurable for regressions.

Address review feedback by splitting oversized Behave and Robot support files into focused modules, trimming repo_indexing_service.py back under the 500-line limit, and rebasing the branch onto current master.

ISSUES CLOSED: #851

# Conflicts:
#	CHANGELOG.md
#	robot/helper_m5_e2e_verification.py
2026-03-30 10:18:19 +00:00

455 lines
20 KiB
Python

"""Step definitions for features/repo_indexing.feature.
Tests for the repository indexing service (issue #195). All context
attributes use the ``repo_index_`` prefix to avoid collisions with
other step files.
"""
from __future__ import annotations
import shutil
import tempfile
import time
from pathlib import Path
from unittest.mock import patch
from behave import given, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.repo_indexing_service import (
RepoIndexingService,
)
from cleveragents.domain.models.core.repo_index import (
FileRecord,
IndexMetadata,
IndexStatus,
)
from cleveragents.infrastructure.database.models import Base
_RESOURCE_ID = "01KK0D8WNATFNEX2JMG5GKF6FM"
# ── Helpers ──────────────────────────────────────────────────────────
def _make_service(context: Context) -> RepoIndexingService:
"""Create an in-memory RepoIndexingService and store on context."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
service = RepoIndexingService(session_factory=factory)
context.repo_index_service = service # type: ignore[attr-defined]
return service
def _make_sample_project(context: Context) -> Path:
"""Create a temp directory with sample project files."""
tmpdir = Path(tempfile.mkdtemp())
(tmpdir / "main.py").write_text("print('hello world')\ndef main():\n pass\n")
(tmpdir / "utils.py").write_text("def helper():\n return 42\n")
(tmpdir / "index.ts").write_text("export const app = () => {};\n")
(tmpdir / "README.md").write_text("# Sample Project\n\nA test project.\n")
(tmpdir / "config.json").write_text('{"key": "value", "debug": true}\n')
(tmpdir / "Makefile").write_text("all:\n\t@echo hello\n")
context.repo_index_tmpdir = str(tmpdir) # type: ignore[attr-defined]
context.add_cleanup(_cleanup_tmpdir, context)
return tmpdir
def _cleanup_tmpdir(context: Context) -> None:
"""Remove the temporary directory."""
tmpdir = getattr(context, "repo_index_tmpdir", None)
if tmpdir:
shutil.rmtree(tmpdir, ignore_errors=True)
# ── Given steps ──────────────────────────────────────────────────────
@given("a repo-index fresh in-memory indexing service")
def step_fresh_service(context: Context) -> None:
"""Set up a fresh in-memory RepoIndexingService."""
_make_service(context)
context.repo_index_result = None # type: ignore[attr-defined]
context.repo_index_original_result = None # type: ignore[attr-defined]
context.repo_index_error = None # type: ignore[attr-defined]
context.repo_index_removal_result = None # type: ignore[attr-defined]
@given("a repo-index temporary directory with sample project files")
def step_sample_project(context: Context) -> None:
"""Create a temporary directory with sample files."""
_make_sample_project(context)
@given("a repo-index temporary directory with sample project files and a 2MB file")
def step_sample_project_with_big_file(context: Context) -> None:
"""Create a temporary directory with sample files plus a 2MB file."""
tmpdir = _make_sample_project(context)
(tmpdir / "big_file.dat").write_bytes(b"x" * 2_000_000)
@given("a repo-index temporary directory with 10000 python files")
def step_large_directory_10000_files(context: Context) -> None:
"""Create a temporary directory containing 10,000 small Python files."""
tmpdir = Path(tempfile.mkdtemp())
created = 0
for i in range(100):
module_dir = tmpdir / f"module_{i:03d}"
module_dir.mkdir(parents=True, exist_ok=True)
for j in range(100):
(module_dir / f"file_{j:03d}.py").write_text(
f"def f_{i}_{j}():\n return {i + j}\n"
)
created += 1
assert created == 10_000, f"Expected 10000 files, created {created}"
context.repo_index_tmpdir = str(tmpdir) # type: ignore[attr-defined]
context.add_cleanup(_cleanup_tmpdir, context)
@given("a repo-index empty temporary directory")
def step_empty_directory(context: Context) -> None:
"""Create an empty temporary directory."""
tmpdir = Path(tempfile.mkdtemp())
context.repo_index_tmpdir = str(tmpdir) # type: ignore[attr-defined]
context.add_cleanup(_cleanup_tmpdir, context)
@given("a repo-index temporary directory with a file that fails hashing")
def step_dir_with_unhashable_file(context: Context) -> None:
"""Create a temp directory with a file that will fail during hashing.
We create a normal file here; the actual OSError is injected via
``unittest.mock.patch`` in the When step. This avoids reliance on
``chmod 0o000`` which is ineffective when running as root.
"""
tmpdir = Path(tempfile.mkdtemp())
(tmpdir / "visible.py").write_text("x = 1\n")
(tmpdir / "secret.dat").write_text("top secret\n")
context.repo_index_tmpdir = str(tmpdir) # type: ignore[attr-defined]
context.add_cleanup(_cleanup_tmpdir, context)
@given("I run repo-index full index on the sample directory")
def step_run_full_index_given(context: Context) -> None:
"""Run a full index (as a Given precondition)."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
result = service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
context.repo_index_result = result # type: ignore[attr-defined]
context.repo_index_original_result = result # type: ignore[attr-defined]
# ── When steps ───────────────────────────────────────────────────────
@when("I run repo-index full index on the sample directory")
def step_run_full_index(context: Context) -> None:
"""Run a full index."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
result = service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
context.repo_index_result = result # type: ignore[attr-defined]
context.repo_index_original_result = result # type: ignore[attr-defined]
@when('I run repo-index full index with hash failure on "{filename}"')
def step_run_full_index_with_hash_failure(context: Context, filename: str) -> None:
"""Run a full index where ``read_and_hash`` raises OSError for *filename*.
This simulates an unreadable file without relying on ``chmod 0o000``
(which is ineffective when the test runner is root).
We patch ``read_and_hash`` in the utils module where
``walk_and_index`` actually calls it.
"""
import cleveragents.application.services.repo_indexing_utils as utils_mod
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
original_read_and_hash = utils_mod.read_and_hash
def _failing_read(path: Path) -> tuple[str, int]:
if Path(path).name == filename:
raise OSError(f"Simulated read failure for {filename}")
return original_read_and_hash(path)
with patch.object(utils_mod, "read_and_hash", _failing_read):
result = service.index_resource(
_RESOURCE_ID,
context.repo_index_tmpdir, # type: ignore[attr-defined]
)
context.repo_index_result = result # type: ignore[attr-defined]
context.repo_index_original_result = result # type: ignore[attr-defined]
@when("I query repo-index for the indexed resource")
def step_query_index(context: Context) -> None:
"""Query the persisted index."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
context.repo_index_retrieved = service.get_index(_RESOURCE_ID) # type: ignore[attr-defined]
@when('I modify repo-index file "{filename}" with new content')
def step_modify_file(context: Context, filename: str) -> None:
"""Modify a file in the temp directory."""
filepath = Path(context.repo_index_tmpdir) / filename # type: ignore[attr-defined]
filepath.write_text("# Modified content\nprint('updated')\n" * 5)
@when("I run repo-index incremental refresh")
def step_run_refresh(context: Context) -> None:
"""Run incremental refresh."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
result = service.refresh_index(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
context.repo_index_result = result # type: ignore[attr-defined]
@when("I run repo-index full index with max file size {size:d} bytes")
def step_run_index_max_file_size(context: Context, size: int) -> None:
"""Run index with max_file_size policy."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
result = service.index_resource(
_RESOURCE_ID,
context.repo_index_tmpdir, # type: ignore[attr-defined]
max_file_size=size,
)
context.repo_index_result = result # type: ignore[attr-defined]
@when('I run repo-index full index with include globs "{globs}"')
def step_run_index_include_globs(context: Context, globs: str) -> None:
"""Run index with include globs."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
glob_tuple = tuple(g.strip() for g in globs.split(","))
result = service.index_resource(
_RESOURCE_ID,
context.repo_index_tmpdir, # type: ignore[attr-defined]
include_globs=glob_tuple,
)
context.repo_index_result = result # type: ignore[attr-defined]
@when('I run repo-index full index with exclude globs "{globs}"')
def step_run_index_exclude_globs(context: Context, globs: str) -> None:
"""Run index with exclude globs."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
glob_tuple = tuple(g.strip() for g in globs.split(","))
result = service.index_resource(
_RESOURCE_ID,
context.repo_index_tmpdir, # type: ignore[attr-defined]
exclude_globs=glob_tuple,
)
context.repo_index_result = result # type: ignore[attr-defined]
@when("I run repo-index full index with max total size {size:d} bytes")
def step_run_index_max_total_size(context: Context, size: int) -> None:
"""Run index with max_total_size policy."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
result = service.index_resource(
_RESOURCE_ID,
context.repo_index_tmpdir, # type: ignore[attr-defined]
max_total_size=size,
)
context.repo_index_result = result # type: ignore[attr-defined]
@when("I run repo-index full index with timeout of {seconds:d} seconds")
def step_run_index_with_timeout(context: Context, seconds: int) -> None:
"""Run index with an explicit timeout and capture elapsed time."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
started = time.monotonic()
result = service.index_resource(
_RESOURCE_ID,
context.repo_index_tmpdir, # type: ignore[attr-defined]
timeout_seconds=float(seconds),
)
context.repo_index_result = result # type: ignore[attr-defined]
context.repo_index_elapsed_seconds = time.monotonic() - started # type: ignore[attr-defined]
@when("I remove the repo-index for the resource")
def step_remove_index(context: Context) -> None:
"""Remove the index."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
context.repo_index_removal_result = service.remove_index(_RESOURCE_ID) # type: ignore[attr-defined]
@when("I remove the repo-index for a non-existent resource")
def step_remove_nonexistent(context: Context) -> None:
"""Attempt to remove an index that doesn't exist."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
context.repo_index_removal_result = service.remove_index(
"01KK0D8WNATFNEX2JMG5GKF6FN"
) # type: ignore[attr-defined]
@when("I query repo-index status for the indexed resource")
def step_query_status(context: Context) -> None:
"""Query the index status (metadata only, no file records)."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
context.repo_index_status = service.get_index_status(_RESOURCE_ID) # type: ignore[attr-defined]
@when("I attempt repo-index with a file path as root")
def step_index_file_root(context: Context) -> None:
"""Attempt indexing with a file path (not a directory)."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
file_path = Path(context.repo_index_tmpdir) / "main.py" # type: ignore[attr-defined]
try:
service.index_resource(_RESOURCE_ID, str(file_path))
except (ValueError, FileNotFoundError) as exc:
context.repo_index_error = exc # type: ignore[attr-defined]
@when('I attempt repo-index refresh with non-existent path "{path}"')
def step_refresh_bad_path(context: Context, path: str) -> None:
"""Attempt refresh with a non-existent path."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
try:
service.refresh_index(_RESOURCE_ID, path)
except FileNotFoundError as exc:
context.repo_index_error = exc # type: ignore[attr-defined]
@when("I attempt repo-index refresh with a file path as root")
def step_refresh_file_root(context: Context) -> None:
"""Attempt refresh with a file path (not a directory)."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
file_path = Path(context.repo_index_tmpdir) / "main.py" # type: ignore[attr-defined]
try:
service.refresh_index(_RESOURCE_ID, str(file_path))
except (ValueError, FileNotFoundError) as exc:
context.repo_index_error = exc # type: ignore[attr-defined]
@when("repo-index walk is mocked to fail and I index a fresh resource")
def step_index_mock_walk_fail_fresh(context: Context) -> None:
"""Mock ``walk_and_index`` to raise OSError and attempt index.
This tests the fault handler at lines 300-325 of the service: when a
walk failure occurs on a *fresh* resource (no prior index), the
service should persist an ERROR status row.
We use ``unittest.mock.patch`` because ``os.walk`` silently skips
unreadable directories on Linux rather than raising.
"""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
with patch(
"cleveragents.application.services.repo_indexing_service.walk_and_index",
side_effect=OSError("Simulated walk failure"),
):
try:
service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
except Exception as exc:
context.repo_index_error = exc # type: ignore[attr-defined]
@when("repo-index walk is mocked to fail and I re-index")
def step_reindex_mock_walk_fail(context: Context) -> None:
"""Mock ``walk_and_index`` to raise OSError and attempt re-index.
Tests the fault handler when a prior index exists: the service should
preserve the previous good index and re-raise.
"""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
with patch(
"cleveragents.application.services.repo_indexing_service.walk_and_index",
side_effect=OSError("Simulated walk failure"),
):
try:
service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
except Exception as exc:
context.repo_index_error = exc # type: ignore[attr-defined]
@when("repo-index walk is mocked to fail and I refresh")
def step_refresh_mock_walk_fail(context: Context) -> None:
"""Mock ``walk_and_index`` to raise OSError and attempt refresh.
Tests the fault handler in ``refresh_index``: the service should
preserve the previous good index and re-raise.
"""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
with patch(
"cleveragents.application.services.repo_indexing_service.walk_and_index",
side_effect=OSError("Simulated walk failure"),
):
try:
service.refresh_index(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
except Exception as exc:
context.repo_index_error = exc # type: ignore[attr-defined]
@when('I add a new file "{filename}" and re-index')
def step_add_file_reindex(context: Context, filename: str) -> None:
"""Add a new file and re-index the same resource."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
filepath = Path(context.repo_index_tmpdir) / filename # type: ignore[attr-defined]
filepath.write_text("# New file\ndef new_func(): pass\n")
result = service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
context.repo_index_result = result # type: ignore[attr-defined]
@when("I create a FileRecord with a naive datetime")
def step_create_filerecord_naive(context: Context) -> None:
"""Create a FileRecord with a naive (no tzinfo) datetime."""
from datetime import datetime as dt
naive = dt(2025, 6, 15, 12, 0, 0)
context.repo_index_filerecord = FileRecord( # type: ignore[attr-defined]
path="test.py",
content_hash="a" * 64,
token_count=10,
language="python",
size_bytes=40,
last_modified=naive,
)
@when("I create an IndexMetadata with a naive datetime")
def step_create_metadata_naive(context: Context) -> None:
"""Create an IndexMetadata with a naive (no tzinfo) datetime."""
from datetime import datetime as dt
naive = dt(2025, 6, 15, 12, 0, 0)
context.repo_index_metadata_naive = IndexMetadata( # type: ignore[attr-defined]
index_id="01KK0D8WNATFNEX2JMG5GKF6FM",
resource_id="01KK0D8WNATFNEX2JMG5GKF6FN",
indexed_at=naive,
file_count=0,
token_estimate=0,
primary_language="unknown",
status=IndexStatus.READY,
)
@when("I attempt repo-index with invalid ULID resource ID")
def step_index_invalid_ulid(context: Context) -> None:
"""Attempt indexing with an invalid ULID resource ID."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
try:
service.index_resource("not-a-valid-ulid", tempfile.gettempdir())
except ValueError as exc:
context.repo_index_error = exc # type: ignore[attr-defined]
@when("I attempt repo-index with empty resource ID")
def step_index_empty_id(context: Context) -> None:
"""Attempt indexing with empty resource ID."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
try:
service.index_resource("", tempfile.gettempdir())
except (ValueError, FileNotFoundError) as exc:
context.repo_index_error = exc # type: ignore[attr-defined]
@when('I attempt repo-index with non-existent path "{path}"')
def step_index_bad_path(context: Context, path: str) -> None:
"""Attempt indexing with a non-existent path."""
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
try:
service.index_resource(_RESOURCE_ID, path)
except FileNotFoundError as exc:
context.repo_index_error = exc # type: ignore[attr-defined]