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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

## [1.0.16] - 2026-09-01

### Changed
- Manifest discovery (`_discover_manifests`) now scans only ancestor directories of changed files instead of walking the entire repository tree — significant performance improvement for large repos
- `_discover_manifests` signature: added `changed_files: list[ChangedFile]` parameter
- Auditor no longer receives per-file metadata: removed `changed_files` input from `AuditSignature` and `forward()` / `_call_auditor()` parameters
- `Auditor.forward()` signature simplified: `Sequence[ChangedFile]` parameter dropped, `all_issues` type narrowed from `Sequence[Issue]` to `list[Issue]`

## [1.0.15] - 2026-09-01

### Changed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "codespy-ai"
version = "1.0.15"
version = "1.0.16"
description = "Code review agent powered by DSPy"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/codespy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""codespy - Code review agent powered by DSPy."""

__version__ = "1.0.15"
__version__ = "1.0.16"
17 changes: 3 additions & 14 deletions src/codespy/agents/reviewer/modules/auditor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Auditor module — assesses code quality and provides recommendation after reviews."""

import logging
from collections.abc import Sequence
from typing import TYPE_CHECKING

import dspy
Expand All @@ -13,7 +12,6 @@
from codespy.agents.reviewer.modules.scope_resolver import _deepest_common_folder
from codespy.config import get_settings
from codespy.config_memory import get_memory_store
from codespy.tools.git.models import ChangedFile

if TYPE_CHECKING:
from codespy.agents.reviewer.models import ScopeResult
Expand All @@ -34,9 +32,6 @@ class AuditSignature(dspy.Signature):

pr_title: str = dspy.InputField(desc="Title of the pull request")
summary: str = dspy.InputField(desc="Summary of what this PR accomplishes")
changed_files: list[ChangedFile] = dspy.InputField(
desc="In-scope reviewable files with status and line counts"
)
all_issues: list[Issue] = dspy.InputField(desc="All issues found during review")

quality_assessment: str = dspy.OutputField(desc="Overall assessment of code quality")
Expand All @@ -55,9 +50,8 @@ def __init__(self) -> None:

def _call_auditor(
self,
auditor: dspy.ChainOfThought,
auditor: dspy.Module,
review_context: ReviewContext,
audit_files: list[ChangedFile],
all_issues: list[Issue],
run_id: str | None,
scopes: list["ScopeResult"] | None,
Expand Down Expand Up @@ -98,7 +92,6 @@ def _call_auditor(
result = mem(
pr_title=review_context.pr_context.pr_title,
summary=review_context.pr_context.summary,
changed_files=audit_files,
all_issues=all_issues,
)
# Run episode save synchronously (auditor is the last module)
Expand All @@ -124,7 +117,6 @@ def _call_auditor(
result = auditor(
pr_title=review_context.pr_context.pr_title,
summary=review_context.pr_context.summary,
changed_files=audit_files,
all_issues=all_issues,
)

Expand All @@ -133,8 +125,7 @@ def _call_auditor(
def forward(
self,
review_context: ReviewContext,
changed_files: Sequence[ChangedFile],
all_issues: Sequence[Issue],
all_issues: list[Issue],
run_id: str | None = None,
scopes: list["ScopeResult"] | None = None,
topics: list["ScopeResult"] | None = None,
Expand All @@ -143,7 +134,6 @@ def forward(

Args:
review_context: ReviewContext containing PR identity (memory loaded per-scope from prior audit episodes)
changed_files: In-scope reviewable files
all_issues: All issues found during review
run_id: Pipeline run identifier
scopes: List of resolved scopes for per-scope episode persistence
Expand Down Expand Up @@ -173,8 +163,7 @@ def forward(
result = self._call_auditor(
auditor,
review_context,
list(changed_files),
list(all_issues),
all_issues,
run_id,
scopes,
topics,
Expand Down
49 changes: 35 additions & 14 deletions src/codespy/agents/reviewer/modules/scope_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ def _resolve(
Tuple of (active scopes, orphan files)
"""
excluded_dirs = self._settings.excluded_directories
manifests = self._discover_manifests(repo_path, excluded_dirs)
manifests = self._discover_manifests(repo_path, changed_files, excluded_dirs)
logger.info(
"Manifest discovery at %s found %d manifest(s): %s",
repo_path,
Expand Down Expand Up @@ -701,39 +701,60 @@ def _resolve(
return active_scopes, orphans

def _discover_manifests(
self, repo_path: Path, excluded_dirs: list[str]
self, repo_path: Path, changed_files: list[ChangedFile], excluded_dirs: list[str]
) -> dict[Path, tuple[str, str]]:
"""Discover all package manifests in the repo.
"""Discover package manifests in ancestor directories of changed files.

Args:
repo_path: Path to the repository root
changed_files: List of changed files to derive ancestor directories from
excluded_dirs: List of directory names to exclude from scanning

Returns:
Dict mapping manifest directory -> (package manager, filename)
"""
logger.debug("Walking %s for manifests (excluded: %s)", repo_path, excluded_dirs)
logger.debug("Scanning ancestor directories for manifests (excluded: %s)", excluded_dirs)
manifests: dict[Path, tuple[str, str]] = {}
excluded_set = set(excluded_dirs)

for root, dirs, files in os.walk(repo_path):
# Skip excluded and hidden directories
dirs[:] = [d for d in dirs if d not in excluded_set and not d.startswith(".")]
# Collect all unique ancestor directories from changed files
ancestor_dirs: set[Path] = set()
ancestor_dirs.add(Path(".")) # Always include root

for changed_file in changed_files:
parts = changed_file.filename.split("/")
# Build each ancestor prefix from the path
for depth in range(1, len(parts)):
ancestor = Path("/".join(parts[:depth]))
ancestor_dirs.add(ancestor)

# Scan each ancestor directory for manifests
for rel_dir in ancestor_dirs:
# Skip if any path component is in excluded_dirs or starts with . (but not root ".")
if rel_dir != Path("."):
dir_name = str(rel_dir)
if any(part in excluded_set or part.startswith(".") for part in dir_name.split("/")):
continue

dir_path = repo_path / rel_dir
if not dir_path.is_dir():
continue

try:
files = os.listdir(dir_path)
except OSError:
continue

for filename in files:
# Check exact matches
if filename in MANIFEST_FILES:
manifest_path = Path(root) / filename
rel_path = manifest_path.relative_to(repo_path)
manifests[rel_path.parent] = (MANIFEST_FILES[filename], filename)
continue
manifests[rel_dir] = (MANIFEST_FILES[filename], filename)
break

# Check glob patterns
for pattern, pkg_mgr in MANIFEST_GLOBS.items():
if fnmatch.fnmatch(filename, pattern):
manifest_path = Path(root) / filename
rel_path = manifest_path.relative_to(repo_path)
manifests[rel_path.parent] = (pkg_mgr, filename)
manifests[rel_dir] = (pkg_mgr, filename)
break

return manifests
Expand Down
29 changes: 0 additions & 29 deletions src/codespy/agents/reviewer/reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,14 +245,8 @@ def forward(self, config: ReviewConfig) -> ReviewResult:
)
logger.info(f"Found {len(all_issues)} issues")
# Step 4: Run Audit (loads own prior episodes per scope, no memory inheritance from parallel modules)
scoped_files = self._collect_scoped_files(scopes)
logger.info(
f"Audit input: {len(scoped_files)} in-scope files "
f"(filtered from {len(pr.changed_files)} total)"
)
quality_assessment, recommendation = self.auditor(
review_context=review_ctx,
changed_files=scoped_files,
all_issues=all_issues,
run_id=run_id,
scopes=scopes,
Expand All @@ -279,29 +273,6 @@ def forward(self, config: ReviewConfig) -> ReviewResult:
signature_stats=signature_stats_list,
)

@staticmethod
def _collect_scoped_files(scopes: list) -> list[ChangedFile]:
"""Collect de-duplicated changed files from identified scopes.

The scope identifier already filters out binaries, vendor directories,
lock files, etc. This method collects only the in-scope files so the
summarizer operates on the same focused set as the review modules.

Args:
scopes: Identified scopes from scope_resolver

Returns:
De-duplicated list of ChangedFile objects from all scopes
"""
seen: set[str] = set()
scoped_files: list[ChangedFile] = []
for scope in scopes:
for f in scope.changed_files:
if f.filename not in seen:
seen.add(f.filename)
scoped_files.append(f)
return scoped_files

def _collect_signature_stats(self) -> list[SignatureStatsResult]:
"""Collect statistics from all signatures that executed.

Expand Down
11 changes: 9 additions & 2 deletions tests/test_scope_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,13 @@ def test_discover_manifests(self):
(repo_path / "services" / "api").mkdir(parents=True)
(repo_path / "services" / "api" / "go.mod").touch()

changed_files = [
ChangedFile(filename="packages/auth/index.ts", status=FileStatus.MODIFIED),
ChangedFile(filename="services/api/main.go", status=FileStatus.MODIFIED),
]

resolver = ScopeResolver()
manifests = resolver._discover_manifests(repo_path, [])
manifests = resolver._discover_manifests(repo_path, changed_files, [])

assert len(manifests) == 2
assert Path("packages/auth") in manifests
Expand Down Expand Up @@ -268,14 +273,16 @@ def test_root_does_not_suppress_when_nested_manifests_exist(self):
(repo_path / "scripts" / "deploy").mkdir(parents=True)

changed_files = [
ChangedFile(filename="packages/auth/src/index.ts", status=FileStatus.MODIFIED),
ChangedFile(filename="scripts/deploy/prod.sh", status=FileStatus.MODIFIED),
]

resolver = ScopeResolver()
scopes, orphans = resolver._resolve(repo_path, changed_files, "owner/repo")

# scripts/deploy indicator should fire — root has nested manifests
# Both packages/auth (manifest scope) and scripts/deploy (indicator scope) should exist
scope_subroots = [s.subroot for s in scopes]
assert "packages/auth" in scope_subroots
assert "scripts/deploy" in scope_subroots


Expand Down
Loading