Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`.

**Test Verification Baseline:** **1224 Automated Tests** (860 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E).
**Test Verification Baseline:** **1231 Automated Tests** (867 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E).

---

Expand Down Expand Up @@ -1131,6 +1131,15 @@ code results are enriched with signature/kind/ast_symbol_id from SQLite._
- `test_mcp_grep_code_registration_and_execution`
- `test_api_search_grep_endpoint`

#### `tests/test_grep_code_context_lines.py` (7 tests)
- `test_indexed_path_regex_context_lines_are_consecutive`
- `test_chunk_path_context_lines_have_file_line_numbers`
- `test_chunk_path_without_file_chunks_gives_consecutive_numbers`
- `test_chunk_context_continues_past_the_chunk_edge`
- `test_sanitizer_renumbers_context_lines_that_share_one_number`
- `test_sanitizer_keeps_correct_context_lines`
- `test_frontmatter_line_offset`

#### `tests/test_grep_code_filters.py` (6 tests)
- `test_path_filter`
- `test_looks_like_regex`
Expand Down
27 changes: 24 additions & 3 deletions app/mcp/handlers/grep_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,34 @@ def truncate_grep_line(text: Optional[str], max_chars: int = _MAX_GREP_LINE_CHAR
return text[:keep] + _LINE_TRUNCATION_SUFFIX


def _context_line_numbers(match: Dict[str, Any], raw_ctx: List[Dict[str, Any]]) -> List[Any]:
"""
Line numbers for the context block. Numbers that do not go up line by line (for example the same number on every
line) are replaced: the context line with the match text gets the match line, and the other lines count from it.
"""
numbers = [item.get("line") for item in raw_ctx]
if all(isinstance(n, int) for n in numbers) and all(b > a for a, b in zip(numbers, numbers[1:])):
return numbers
match_line = match.get("line")
if not isinstance(match_line, int):
return numbers
texts = [item.get("text") for item in raw_ctx]
anchor = next((i for i, (n, t) in enumerate(zip(numbers, texts)) if n == match_line and t == match.get("match_text")), None)
if anchor is None:
anchor = next((i for i, t in enumerate(texts) if t == match.get("match_text")), None)
if anchor is None:
return numbers
return [match_line - anchor + i for i in range(len(raw_ctx))]


def _sanitize_match_lines(match: Dict[str, Any]) -> Dict[str, Any]:
sanitized = dict(match)
sanitized["match_text"] = truncate_grep_line(sanitized.get("match_text"))
raw_ctx = sanitized.get("context") or []
raw_ctx = [item for item in (match.get("context") or []) if isinstance(item, dict)]
numbers = _context_line_numbers(match, raw_ctx)
sanitized["context"] = [
{"line": item.get("line"), "text": truncate_grep_line(item.get("text"))}
for item in raw_ctx
{"line": line_no, "text": truncate_grep_line(item.get("text"))}
for line_no, item in zip(numbers, raw_ctx)
]
return sanitized

Expand Down
149 changes: 149 additions & 0 deletions app/services/code_grep_chunks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""
grep_code matches from vector-store chunks.

The chunks of one file are put back together as one line map before the scan. Each context line then gets its own
file line number, and the context can continue past the edge of the chunk that holds the match.
"""
import logging
from typing import Any, Callable, Dict, Iterable, List, Optional, Pattern, Set, Tuple

logger = logging.getLogger("contextcortex.file_reader")

FileKey = Tuple[str, str]


def chunk_file_key(chunk: Dict[str, Any]) -> FileKey:
"""(repo, path in the repo) for a chunk payload, with a 'repo://' or 'repo/' prefix removed."""
ch_repo = chunk.get("repo") or "unknown"
raw_file = (chunk.get("rel_path") or chunk.get("path") or "").replace("\\", "/")
ch_file = raw_file.split("://", 1)[1] if "://" in raw_file else raw_file
if ch_file.startswith(f"{ch_repo}/"):
ch_file = ch_file[len(ch_repo) + 1:]
return ch_repo, ch_file


def is_summary_chunk(chunk: Dict[str, Any]) -> bool:
"""File summaries are LLM text with placeholder line numbers, so grep must not report lines from them."""
return chunk.get("doc_type") == "summary" or bool(chunk.get("is_summary"))


def _positive_int(value: Any) -> Optional[int]:
try:
number = int(value)
except (TypeError, ValueError):
return None
return number if number >= 1 else None


def _same_line(known: str, text: str) -> bool:
known_s, text_s = known.strip(), text.strip()
if not text_s:
return not known_s
# A nested symbol chunk can start or stop in the middle of a line of its parent chunk.
return known_s == text_s or known_s.endswith(text_s) or known_s.startswith(text_s)


def _fit_shift(line_map: Dict[int, str], blank_lines: Set[int], start: int, lines: List[str], slack: int) -> int:
"""
Chunk text is stripped when it is indexed, so blank lines at the start or end of a chunk can be missing. `slack`
is the number of missing lines. Use the smallest shift at which the chunk agrees with the known lines.
"""
for shift in range(slack + 1):
agrees = True
for idx, text in enumerate(lines):
line_no = start + shift + idx
known = line_map.get(line_no, "" if line_no in blank_lines else None)
if known is not None and not _same_line(known, text):
agrees = False
break
if agrees:
return shift
return 0


def build_line_map(chunks: Iterable[Dict[str, Any]]) -> Dict[int, str]:
"""Maps file line numbers to text for the chunks of one file. Large chunks go first, so full lines win."""
ordered = sorted(
chunks,
key=lambda c: (_positive_int(c.get("start_line")) or 0, -(_positive_int(c.get("end_line")) or 0)),
)
line_map: Dict[int, str] = {}
blank_lines: Set[int] = set()
next_line = 1
for chunk in ordered:
lines = (chunk.get("content") or chunk.get("text") or "").splitlines()
if not lines:
continue
start = _positive_int(chunk.get("start_line")) or next_line
end = _positive_int(chunk.get("end_line"))
slack = max(0, end - start + 1 - len(lines)) if end and end >= start else 0
shift = _fit_shift(line_map, blank_lines, start, lines, slack)
# The lines that the strip removed were blank.
blank_lines.update(range(start, start + shift))
blank_lines.update(range(start + shift + len(lines), start + slack + len(lines)))
start += shift
for idx, text in enumerate(lines):
line_map.setdefault(start + idx, text)
next_line = max(next_line, start + len(lines))
for line_no in blank_lines:
line_map.setdefault(line_no, "")
return line_map


def grep_chunk_matches(
chunks: Iterable[Dict[str, Any]],
compiled: Pattern[str],
context_lines: int,
limit: int,
skip_files: Set[FileKey],
seen_keys: Set[Tuple[str, str, int]],
load_file_chunks: Optional[Callable[[str, str], List[Dict[str, Any]]]] = None,
) -> List[Dict[str, Any]]:
"""
Returns up to `limit` matches from the files of the matched chunks. Files in `skip_files` were scanned on disk.
`load_file_chunks(repo, file)` gives all chunks of a file; without it only the matched chunks are used.
"""
grouped: Dict[FileKey, List[Dict[str, Any]]] = {}
for chunk in chunks:
if is_summary_chunk(chunk):
continue
key = chunk_file_key(chunk)
if key in skip_files:
continue
grouped.setdefault(key, []).append(chunk)

matches: List[Dict[str, Any]] = []
for (ch_repo, ch_file), matched_chunks in grouped.items():
file_chunks = matched_chunks
if load_file_chunks is not None:
try:
loaded = [c for c in (load_file_chunks(ch_repo, ch_file) or []) if not is_summary_chunk(c)]
if loaded:
file_chunks = loaded
except Exception as e:
logger.debug(f"get_file_chunks failed for {ch_repo}:{ch_file}: {e}")

line_map = build_line_map(file_chunks)
for line_no in sorted(line_map):
line_str = line_map[line_no]
if not compiled.search(line_str):
continue
key = (ch_repo, ch_file, line_no)
if key in seen_keys:
continue
seen_keys.add(key)
context = [
{"line": c_line, "text": line_map[c_line]}
for c_line in range(line_no - context_lines, line_no + context_lines + 1)
if c_line in line_map
]
matches.append({
"repo": ch_repo,
"file": ch_file,
"line": line_no,
"match_text": line_str,
"context": context,
})
if len(matches) >= limit:
return matches
return matches
52 changes: 13 additions & 39 deletions app/services/code_grep_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import re
from typing import Any, Callable, Dict, List, Optional, Tuple

from app.services.code_grep_chunks import grep_chunk_matches
from app.services.git_manager import format_git_permalink
from app.services.text_decoding import read_source_text

Expand Down Expand Up @@ -196,45 +197,18 @@ def execute_code_grep(
case_sensitive=case_sensitive,
limit=max(10, remaining_limit),
)
for ch in chunks:
ch_repo = ch.get("repo") or "unknown"
raw_ch_file = (ch.get("rel_path") or ch.get("path") or "").replace("\\", "/")
ch_file = raw_ch_file.split("://", 1)[1] if "://" in raw_ch_file else raw_ch_file
if ch_file.startswith(f"{ch_repo}/"):
ch_file = ch_file[len(ch_repo) + 1:]

if (ch_repo, ch_file) in disk_scanned_keys:
continue

text_content = ch.get("content") or ch.get("text") or ""
start_line = int(ch.get("start_line") or 1)
ch_lines = text_content.splitlines()

for c_idx, line_str in enumerate(ch_lines):
curr_line = start_line + c_idx
if compiled.search(line_str):
key = (ch_repo, ch_file, curr_line)
if key in seen_keys:
continue
seen_keys.add(key)

start_ctx = max(0, c_idx - clamped_context)
end_ctx = min(len(ch_lines), c_idx + clamped_context + 1)
context_items = [
{"line": start_line + idx, "text": ch_lines[idx]}
for idx in range(start_ctx, end_ctx)
]
matches.append({
"repo": ch_repo,
"file": ch_file,
"line": curr_line,
"match_text": line_str,
"context": context_items,
})
if len(matches) >= clamped_limit:
break
if len(matches) >= clamped_limit:
break
load_fn = None
if hasattr(store, "get_file_chunks"):
load_fn = lambda r_name, r_file: store.get_file_chunks(path=r_file, repo=r_name)
matches.extend(grep_chunk_matches(
chunks=chunks,
compiled=compiled,
context_lines=clamped_context,
limit=clamped_limit - len(matches),
skip_files=disk_scanned_keys,
seen_keys=seen_keys,
load_file_chunks=load_fn,
))
except Exception as e:
logger.warning(f"grep_chunks fallback in grep_code encountered error: {e}")

Expand Down
23 changes: 23 additions & 0 deletions app/services/indexing/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ def _get_indexer_attr(name, default):
from app.services.pdf_extractor import MAX_PDF_SIZE_BYTES


def frontmatter_line_offset(text: str, body: str) -> int:
"""
Number of file lines before `body`, the text that frontmatter.loads keeps. It removes the frontmatter and the
blank lines around the body, so chunk line numbers that count from the body must move down by this number.
"""
body_lines = [line.rstrip() for line in body.splitlines()[:5]]
if not body_lines:
return 0
file_lines = [line.rstrip() for line in text.splitlines()]
for offset in range(len(file_lines) - len(body_lines) + 1):
if file_lines[offset:offset + len(body_lines)] == body_lines:
return offset
return 0


def compute_text_hash(text: str) -> str:
"""Computes SHA256 hash of utf-8 text."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
Expand Down Expand Up @@ -240,6 +255,7 @@ def process_file_content(
return points, ast_symbols, summary_tuple, ast_relationships, api_routes, api_calls

if doc_type in ("doc", "pdf"):
body_line_offset = 0
if filepath.endswith((".md", ".txt")):
# Check if file is in standard ADR directories or follows ADR naming
norm_rel = rel_path.replace("\\", "/").lower()
Expand All @@ -252,6 +268,7 @@ def process_file_content(

try:
post = frontmatter.loads(content)
body_line_offset = frontmatter_line_offset(content, post.content)
content = post.content
meta = post.metadata or {}
if "category" in meta:
Expand All @@ -267,6 +284,12 @@ def process_file_content(

chunks_models = chunk_markdown(content, max_chars=CHUNK_SIZE, overlap=CHUNK_OVERLAP)
chunks = [c.model_dump() for c in chunks_models]
if body_line_offset:
# Chunk lines count from the body; shift them so they are file line numbers.
for item in chunks + ast_relationships:
for line_key in ("start_line", "end_line", "line_number"):
if isinstance(item.get(line_key), int):
item[line_key] += body_line_offset
valid_chunks = [c for c in chunks if c.get("content", "").strip()]
headings = [c["heading"] for c in valid_chunks if c.get("heading") and c["heading"] != "Root"]
keywords = extract_keywords_from_text(content, title, headings, tags)
Expand Down
Loading
Loading