codespy-ai 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/PKG-INFO +4 -4
  2. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/README.md +3 -3
  3. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/pyproject.toml +1 -1
  4. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/__init__.py +1 -1
  5. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/modules/code_and_doc_reviewer.py +72 -53
  6. codespy_ai-0.2.3/src/codespy/agents/reviewer/modules/helpers.py +189 -0
  7. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/modules/supply_chain_auditor.py +101 -44
  8. codespy_ai-0.2.2/src/codespy/agents/reviewer/modules/helpers.py +0 -65
  9. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/LICENSE +0 -0
  10. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/__init__.py +0 -0
  11. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/cost_tracker.py +0 -0
  12. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/dspy_config.py +0 -0
  13. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/__init__.py +0 -0
  14. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/models.py +0 -0
  15. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/modules/__init__.py +0 -0
  16. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/modules/deduplicator.py +0 -0
  17. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/modules/scope_identifier.py +0 -0
  18. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/reporters/__init__.py +0 -0
  19. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/reporters/base.py +0 -0
  20. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/reporters/git.py +0 -0
  21. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/reporters/stdout.py +0 -0
  22. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/agents/reviewer/reviewer.py +0 -0
  23. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/cli.py +0 -0
  24. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/config.py +0 -0
  25. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/config_dspy.py +0 -0
  26. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/config_git.py +0 -0
  27. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/config_io.py +0 -0
  28. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/config_llm.py +0 -0
  29. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/__init__.py +0 -0
  30. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/cyber/__init__.py +0 -0
  31. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/cyber/osv/__init__.py +0 -0
  32. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/cyber/osv/client.py +0 -0
  33. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/cyber/osv/models.py +0 -0
  34. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/cyber/osv/server.py +0 -0
  35. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/filesystem/__init__.py +0 -0
  36. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/filesystem/client.py +0 -0
  37. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/filesystem/models.py +0 -0
  38. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/filesystem/server.py +0 -0
  39. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/git/__init__.py +0 -0
  40. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/git/base.py +0 -0
  41. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/git/client.py +0 -0
  42. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/git/github_client.py +0 -0
  43. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/git/gitlab_client.py +0 -0
  44. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/git/models.py +0 -0
  45. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/git/server.py +0 -0
  46. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/mcp_utils.py +0 -0
  47. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/__init__.py +0 -0
  48. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/ripgrep/__init__.py +0 -0
  49. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/ripgrep/client.py +0 -0
  50. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/ripgrep/server.py +0 -0
  51. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/__init__.py +0 -0
  52. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/base_extractor.py +0 -0
  53. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/extractors/__init__.py +0 -0
  54. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/extractors/go.py +0 -0
  55. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/extractors/java.py +0 -0
  56. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/extractors/javascript.py +0 -0
  57. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/extractors/kotlin.py +0 -0
  58. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/extractors/objc.py +0 -0
  59. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/extractors/python.py +0 -0
  60. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/extractors/rust.py +0 -0
  61. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/extractors/swift.py +0 -0
  62. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/extractors/terraform.py +0 -0
  63. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/models.py +0 -0
  64. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/parser.py +0 -0
  65. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/parsers/treesitter/server.py +0 -0
  66. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/web/__init__.py +0 -0
  67. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/web/client.py +0 -0
  68. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/web/models.py +0 -0
  69. {codespy_ai-0.2.2 → codespy_ai-0.2.3}/src/codespy/tools/web/server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codespy-ai
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Code review agent powered by DSPy
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -254,7 +254,7 @@ docker run --rm \
254
254
 
255
255
  Add CodeSpy to your repository for automatic PR reviews:
256
256
 
257
- **Trigger on `@codespy review` comment:**
257
+ **Trigger on `/codespy review` comment:**
258
258
 
259
259
  ```yaml
260
260
  # .github/workflows/codespy-review.yml
@@ -266,10 +266,10 @@ on:
266
266
 
267
267
  jobs:
268
268
  review:
269
- # Only run on PR comments containing '@codespy review'
269
+ # Only run on PR comments containing '/codespy review'
270
270
  if: |
271
271
  github.event.issue.pull_request &&
272
- contains(github.event.comment.body, '@codespy review')
272
+ contains(github.event.comment.body, '/codespy review')
273
273
  runs-on: ubuntu-latest
274
274
  permissions:
275
275
  contents: read
@@ -203,7 +203,7 @@ docker run --rm \
203
203
 
204
204
  Add CodeSpy to your repository for automatic PR reviews:
205
205
 
206
- **Trigger on `@codespy review` comment:**
206
+ **Trigger on `/codespy review` comment:**
207
207
 
208
208
  ```yaml
209
209
  # .github/workflows/codespy-review.yml
@@ -215,10 +215,10 @@ on:
215
215
 
216
216
  jobs:
217
217
  review:
218
- # Only run on PR comments containing '@codespy review'
218
+ # Only run on PR comments containing '/codespy review'
219
219
  if: |
220
220
  github.event.issue.pull_request &&
221
- contains(github.event.comment.body, '@codespy review')
221
+ contains(github.event.comment.body, '/codespy review')
222
222
  runs-on: ubuntu-latest
223
223
  permissions:
224
224
  contents: read
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "codespy-ai"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Code review agent powered by DSPy"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """codespy - Code review agent powered by DSPy."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -9,7 +9,7 @@ import dspy # type: ignore[import-untyped]
9
9
 
10
10
  from codespy.agents import SignatureContext, get_cost_tracker
11
11
  from codespy.agents.reviewer.models import Issue, IssueCategory, ScopeResult
12
- from codespy.agents.reviewer.modules.helpers import MIN_CONFIDENCE
12
+ from codespy.agents.reviewer.modules.helpers import MIN_CONFIDENCE, make_scope_relative, resolve_scope_root, restore_repo_paths
13
13
  from codespy.config import get_settings
14
14
  from codespy.tools.mcp_utils import cleanup_mcp_contexts, connect_mcp_server
15
15
 
@@ -21,16 +21,16 @@ class CodeAndDocReviewSignature(dspy.Signature):
21
21
 
22
22
  You are a busy Principal Engineer with very little time.
23
23
  Be extremely terse. Use imperative mood ("Fix X", not "You should fix X").
24
- You have tools to explore the repository filesystem, search for text, and analyze code.
25
- All file paths are relative to the repository root.
24
+ You have tools to explore the scope's filesystem, search for text, and analyze code.
25
+ All file paths are relative to the scope root directory (the current tool root).
26
+ Tools are restricted to this scope — you cannot access files outside it.
26
27
 
27
28
  ═══════════════════════════════════════════════════════════════════════════════
28
29
  PHASE 1 — READ DOCUMENTATION (MANDATORY FIRST STEP)
29
30
  ═══════════════════════════════════════════════════════════════════════════════
30
31
 
31
32
  Use read_file to read the README at the scope root:
32
- - Path: {scope.subroot}/readme.md OR {scope.subroot}/README.md
33
- - If scope.subroot is "peaks/svc/authenticator-v1", read "peaks/svc/authenticator-v1/readme.md"
33
+ - Path: readme.md OR README.md (paths are relative to scope root)
34
34
  - This file provides essential context about the scope's purpose, API contracts,
35
35
  configuration, and expected behavior. Use it to inform BOTH documentation
36
36
  review AND defect detection.
@@ -126,7 +126,7 @@ class CodeAndDocReviewSignature(dspy.Signature):
126
126
  OUTPUT RULES
127
127
  ═══════════════════════════════════════════════════════════════════════════════
128
128
 
129
- - Set category to "bug", "security", or "documentation" per issue
129
+ - Set category to one of the values provided in the categories input
130
130
  - For security issues, include cwe_id where applicable
131
131
  - Reference files by name and line number only — never copy source code into issues
132
132
  - Do not repeat patch content in reasoning steps. Keep each reasoning step to 1-2 sentences
@@ -138,12 +138,17 @@ class CodeAndDocReviewSignature(dspy.Signature):
138
138
 
139
139
  scope: ScopeResult = dspy.InputField(
140
140
  desc="Scope with changed files. Has: subroot, scope_type, "
141
- "changed_files (filename + patch - analyze patch first), language, package_manifest."
141
+ "changed_files (filename + patch - analyze patch first), language, package_manifest. "
142
+ "File paths in changed_files are relative to the scope root (tool root)."
143
+ )
144
+ categories: list[IssueCategory] = dspy.InputField(
145
+ desc="Allowed issue categories. Use only these values for the 'category' field on each issue."
142
146
  )
143
147
 
144
148
  issues: list[Issue] = dspy.OutputField(
145
- desc="Verified defects and documentation issues. Set category to 'bug', 'security', or 'documentation' per issue. "
146
- "Titles <10 words. Descriptions ≤25 words, imperative. Empty list if none."
149
+ desc="Verified defects and documentation issues. Category must be one of the provided categories. "
150
+ "Titles <10 words. Descriptions ≤25 words, imperative. Empty list if none. "
151
+ "File paths must be relative to scope root."
147
152
  )
148
153
 
149
154
 
@@ -152,6 +157,9 @@ class CodeAndDocReviewer(dspy.Module):
152
157
 
153
158
  Merges the responsibilities of DefectDetector and DocumentationReviewer
154
159
  to share MCP tool overhead and README context across both concerns.
160
+
161
+ MCP tools are scope-restricted: for each scope, tools are rooted at
162
+ repo_path/scope.subroot so the agent cannot access files outside the scope.
155
163
  """
156
164
 
157
165
  def __init__(self) -> None:
@@ -160,11 +168,11 @@ class CodeAndDocReviewer(dspy.Module):
160
168
  self._cost_tracker = get_cost_tracker()
161
169
  self._settings = get_settings()
162
170
 
163
- async def _create_mcp_tools(self, repo_path: Path) -> tuple[list[Any], list[Any]]:
164
- """Create DSPy tools from MCP servers.
171
+ async def _create_mcp_tools(self, scope_root: Path) -> tuple[list[Any], list[Any]]:
172
+ """Create DSPy tools from MCP servers, rooted at scope directory.
165
173
 
166
174
  Args:
167
- repo_path: Path to the repository root
175
+ scope_root: Path to the scope root directory (repo_path / scope.subroot)
168
176
 
169
177
  Returns:
170
178
  Tuple of (tools list, contexts list for cleanup)
@@ -172,24 +180,24 @@ class CodeAndDocReviewer(dspy.Module):
172
180
  tools: list[Any] = []
173
181
  contexts: list[Any] = []
174
182
  tools_dir = Path(__file__).parent.parent.parent.parent / "tools"
175
- repo_path_str = str(repo_path)
183
+ scope_root_str = str(scope_root)
176
184
  caller = "code_and_doc_reviewer"
177
185
  # Filesystem tools: read_file, list_directory, get_tree, file_exists, get_file_info
178
186
  tools.extend(
179
187
  await connect_mcp_server(
180
- tools_dir / "filesystem" / "server.py", [repo_path_str], contexts, caller
188
+ tools_dir / "filesystem" / "server.py", [scope_root_str], contexts, caller
181
189
  )
182
190
  )
183
191
  # Ripgrep tools: search_literal, find_function_usages, find_type_usages, etc.
184
192
  tools.extend(
185
193
  await connect_mcp_server(
186
- tools_dir / "parsers" / "ripgrep" / "server.py", [repo_path_str], contexts, caller
194
+ tools_dir / "parsers" / "ripgrep" / "server.py", [scope_root_str], contexts, caller
187
195
  )
188
196
  )
189
197
  # Treesitter tools: find_function_definitions, find_function_calls, etc.
190
198
  tools.extend(
191
199
  await connect_mcp_server(
192
- tools_dir / "parsers" / "treesitter" / "server.py", [repo_path_str], contexts, caller
200
+ tools_dir / "parsers" / "treesitter" / "server.py", [scope_root_str], contexts, caller
193
201
  )
194
202
  )
195
203
  return tools, contexts
@@ -199,9 +207,13 @@ class CodeAndDocReviewer(dspy.Module):
199
207
  ) -> list[Issue]:
200
208
  """Analyze scopes for code defects and documentation issues.
201
209
 
210
+ For each scope, MCP tools are created rooted at repo_path/scope.subroot
211
+ so the agent can only access files within the scope boundary. This prevents
212
+ unnecessary out-of-scope tool calls that waste tokens and cost.
213
+
202
214
  Args:
203
215
  scopes: List of identified scopes with their changed files
204
- repo_path: Path to the cloned repository (used for MCP tool initialization)
216
+ repo_path: Path to the cloned repository
205
217
 
206
218
  Returns:
207
219
  List of issues (bugs, security, documentation) found across all scopes
@@ -216,46 +228,53 @@ class CodeAndDocReviewer(dspy.Module):
216
228
  return []
217
229
 
218
230
  all_issues: list[Issue] = []
219
- tools, contexts = await self._create_mcp_tools(repo_path)
220
-
221
- try:
222
- max_iters = self._settings.get_max_iters("code_and_doc_review")
223
- agent = dspy.ReAct(
224
- signature=CodeAndDocReviewSignature,
225
- tools=tools,
226
- max_iters=max_iters,
227
- )
231
+ max_iters = self._settings.get_max_iters("code_and_doc_review")
228
232
 
229
- total_files = sum(len(s.changed_files) for s in changed_scopes)
230
- logger.info(
231
- f"Reviewing code and docs for {len(changed_scopes)} scopes "
232
- f"({total_files} changed files)..."
233
- )
233
+ total_files = sum(len(s.changed_files) for s in changed_scopes)
234
+ logger.info(
235
+ f"Reviewing code and docs for {len(changed_scopes)} scopes "
236
+ f"({total_files} changed files)..."
237
+ )
234
238
 
235
- for scope in changed_scopes:
236
- try:
237
- logger.info(
238
- f" Reviewing scope {scope.subroot} "
239
- f"({len(scope.changed_files)} files)"
240
- )
241
- async with SignatureContext("code_and_doc_review", self._cost_tracker):
242
- result = await agent.acall(scope=scope)
243
-
244
- issues = [
245
- issue for issue in (result.issues or [])
246
- if issue.confidence >= MIN_CONFIDENCE
247
- ]
248
- all_issues.extend(issues)
249
- logger.debug(
250
- f" Scope {scope.subroot}: {len(issues)} issues"
239
+ for scope in changed_scopes:
240
+ # Scope-restrict MCP tools to the scope's subroot directory
241
+ scope_root = resolve_scope_root(repo_path, scope.subroot)
242
+ tools, contexts = await self._create_mcp_tools(scope_root)
243
+ try:
244
+ agent = dspy.ReAct(
245
+ signature=CodeAndDocReviewSignature,
246
+ tools=tools,
247
+ max_iters=max_iters,
248
+ )
249
+ # Create scope-relative copy so file paths match the scoped tool root
250
+ scoped = make_scope_relative(scope)
251
+ logger.info(
252
+ f" Reviewing scope {scope.subroot} "
253
+ f"({len(scope.changed_files)} files)"
254
+ )
255
+ async with SignatureContext("code_and_doc_review", self._cost_tracker):
256
+ result = await agent.acall(
257
+ scope=scoped,
258
+ categories=[IssueCategory.BUG, IssueCategory.SECURITY, IssueCategory.DOCUMENTATION],
251
259
  )
252
- except Exception as e:
253
- logger.error(f"Review failed for scope {scope.subroot}: {e}")
254
260
 
255
- logger.info(f"Code and doc review found {len(all_issues)} issues")
256
- return all_issues
257
- finally:
258
- await cleanup_mcp_contexts(contexts)
261
+ issues = [
262
+ issue for issue in (result.issues or [])
263
+ if issue.confidence >= MIN_CONFIDENCE
264
+ ]
265
+ # Restore repo-root-relative paths in reported issues
266
+ restore_repo_paths(issues, scope.subroot)
267
+ all_issues.extend(issues)
268
+ logger.debug(
269
+ f" Scope {scope.subroot}: {len(issues)} issues"
270
+ )
271
+ except Exception as e:
272
+ logger.error(f"Review failed for scope {scope.subroot}: {e}")
273
+ finally:
274
+ await cleanup_mcp_contexts(contexts)
275
+
276
+ logger.info(f"Code and doc review found {len(all_issues)} issues")
277
+ return all_issues
259
278
 
260
279
  def forward(
261
280
  self, scopes: Sequence[ScopeResult], repo_path: Path
@@ -0,0 +1,189 @@
1
+ """Helper functions for DSPy review modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from codespy.tools.git.models import ChangedFile
11
+ from codespy.agents.reviewer.models import Issue
12
+
13
+ if TYPE_CHECKING:
14
+ from codespy.agents.reviewer.models import ScopeResult
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # Language detection based on file extension
20
+ EXTENSION_TO_LANGUAGE = {
21
+ "py": "Python",
22
+ "js": "JavaScript",
23
+ "ts": "TypeScript",
24
+ "jsx": "JavaScript (React)",
25
+ "tsx": "TypeScript (React)",
26
+ "go": "Go",
27
+ "rs": "Rust",
28
+ "java": "Java",
29
+ "kt": "Kotlin",
30
+ "c": "C",
31
+ "cpp": "C++",
32
+ "h": "C/C++ Header",
33
+ "hpp": "C++ Header",
34
+ "cs": "C#",
35
+ "rb": "Ruby",
36
+ "php": "PHP",
37
+ "swift": "Swift",
38
+ "scala": "Scala",
39
+ "sh": "Shell",
40
+ "bash": "Bash",
41
+ "sql": "SQL",
42
+ "vue": "Vue",
43
+ "svelte": "Svelte",
44
+ }
45
+
46
+ # Minimum confidence threshold
47
+ MIN_CONFIDENCE = 0.5
48
+
49
+ # Markdown file extensions to review
50
+ MARKDOWN_EXTENSIONS = {".md", ".markdown", ".mdx", ".rst", ".txt"}
51
+
52
+
53
+ def is_markdown_file(filename: str) -> bool:
54
+ """Check if the file is a markdown documentation file."""
55
+ _, ext = os.path.splitext(filename.lower())
56
+ return ext in MARKDOWN_EXTENSIONS
57
+
58
+
59
+ def get_language(file: ChangedFile) -> str:
60
+ """Get the programming language for a file based on extension.
61
+
62
+ Args:
63
+ file: The changed file
64
+
65
+ Returns:
66
+ Language name or "Unknown"
67
+ """
68
+ return EXTENSION_TO_LANGUAGE.get(file.extension, "Unknown")
69
+
70
+
71
+ def strip_prefix(path: str, prefix: str) -> str:
72
+ """Strip a directory prefix from a file path.
73
+
74
+ Args:
75
+ path: Original file path (e.g., "packages/auth/src/index.ts")
76
+ prefix: Prefix to strip (e.g., "packages/auth")
77
+
78
+ Returns:
79
+ Relative path (e.g., "src/index.ts"). Returns path unchanged
80
+ if prefix is "." or path doesn't start with the prefix.
81
+ """
82
+ if prefix == "." or not prefix:
83
+ return path
84
+ # Normalize: ensure prefix ends with "/" for clean stripping
85
+ normalized = prefix.rstrip("/") + "/"
86
+ if path.startswith(normalized):
87
+ return path[len(normalized):]
88
+ # Exact match (file at scope root)
89
+ if path == prefix:
90
+ return os.path.basename(path)
91
+ return path
92
+
93
+
94
+ def resolve_scope_root(repo_path: Path, subroot: str) -> Path:
95
+ """Resolve the absolute path for a scope root directory.
96
+
97
+ Args:
98
+ repo_path: Path to the repository root
99
+ subroot: Scope subroot relative to repo root (e.g., "packages/auth" or ".")
100
+
101
+ Returns:
102
+ repo_path unchanged if subroot is ".", otherwise repo_path / subroot
103
+ """
104
+ return repo_path if subroot == "." else repo_path / subroot
105
+
106
+
107
+ def make_scope_relative(scope: ScopeResult) -> ScopeResult:
108
+ """Create a copy of a ScopeResult with file paths relative to scope.subroot.
109
+
110
+ When MCP tools are rooted at repo_path/scope.subroot, the agent needs file
111
+ paths relative to the scope root (not the repo root). This function creates
112
+ a shallow copy of the scope with adjusted filenames.
113
+
114
+ Args:
115
+ scope: Original scope with repo-root-relative file paths
116
+
117
+ Returns:
118
+ New ScopeResult with scope-relative file paths in changed_files.
119
+ The subroot is set to "." since paths are now relative to it.
120
+ """
121
+ from codespy.agents.reviewer.models import PackageManifest, ScopeResult as SR
122
+
123
+ if scope.subroot == ".":
124
+ return scope # Already at repo root, no transformation needed
125
+
126
+ relative_files = [
127
+ ChangedFile(
128
+ filename=strip_prefix(f.filename, scope.subroot),
129
+ status=f.status,
130
+ additions=f.additions,
131
+ deletions=f.deletions,
132
+ patch=f.patch,
133
+ previous_filename=(
134
+ strip_prefix(f.previous_filename, scope.subroot)
135
+ if f.previous_filename
136
+ else None
137
+ ),
138
+ )
139
+ for f in scope.changed_files
140
+ ]
141
+
142
+ # Adjust manifest paths too
143
+ manifest = None
144
+ if scope.package_manifest:
145
+ manifest = PackageManifest(
146
+ manifest_path=strip_prefix(scope.package_manifest.manifest_path, scope.subroot),
147
+ lock_file_path=(
148
+ strip_prefix(scope.package_manifest.lock_file_path, scope.subroot)
149
+ if scope.package_manifest.lock_file_path
150
+ else None
151
+ ),
152
+ package_manager=scope.package_manifest.package_manager,
153
+ dependencies_changed=scope.package_manifest.dependencies_changed,
154
+ )
155
+
156
+ return SR(
157
+ subroot=".",
158
+ scope_type=scope.scope_type,
159
+ has_changes=scope.has_changes,
160
+ is_dependency=scope.is_dependency,
161
+ confidence=scope.confidence,
162
+ language=scope.language,
163
+ package_manifest=manifest,
164
+ changed_files=relative_files,
165
+ reason=scope.reason,
166
+ )
167
+
168
+
169
+ def restore_repo_paths(issues: list[Issue], subroot: str) -> None:
170
+ """Restore repo-root-relative paths in issue filenames (in-place).
171
+
172
+ After the agent reports issues with scope-relative paths, this function
173
+ prepends the scope subroot so filenames are repo-root-relative again.
174
+
175
+ Args:
176
+ issues: List of issues to modify in-place
177
+ subroot: Scope subroot to prepend (e.g., "packages/auth")
178
+ """
179
+ if subroot == "." or not subroot:
180
+ return
181
+
182
+ prefix = subroot.rstrip("/") + "/"
183
+ for issue in issues:
184
+ if issue.filename and not issue.filename.startswith(prefix):
185
+ issue.filename = prefix + issue.filename
186
+
187
+
188
+
189
+
@@ -9,7 +9,7 @@ import dspy # type: ignore[import-untyped]
9
9
 
10
10
  from codespy.agents import SignatureContext, get_cost_tracker
11
11
  from codespy.agents.reviewer.models import Issue, IssueCategory, ScopeResult
12
- from codespy.agents.reviewer.modules.helpers import MIN_CONFIDENCE
12
+ from codespy.agents.reviewer.modules.helpers import MIN_CONFIDENCE, resolve_scope_root, strip_prefix, restore_repo_paths
13
13
  from codespy.config import get_settings
14
14
  from codespy.tools.mcp_utils import cleanup_mcp_contexts, connect_mcp_server
15
15
 
@@ -22,16 +22,19 @@ class SupplyChainSecuritySignature(dspy.Signature):
22
22
  You are a busy Principal Engineer reviewing supply chain security. Focus on critical risks only.
23
23
  Be extremely terse. Use imperative mood.
24
24
  You have access to:
25
- - Filesystem tools to read files and explore the codebase
25
+ - Filesystem tools to read files and explore the scope (rooted at scope directory)
26
26
  - OSV (Open Source Vulnerabilities) tools to query real vulnerability data
27
27
 
28
+ All file paths are relative to the scope root directory (the current tool root).
29
+ Tools are restricted to this scope — you cannot access files outside it.
30
+
28
31
  You will analyze TWO types of supply chain security concerns:
29
32
 
30
33
  ## 1. DOCKERFILE SECURITY
31
34
 
32
35
  FIRST, search for Dockerfiles in the scope using filesystem tools:
33
36
  - Use get_tree or list_directory to find Dockerfile, Dockerfile.*, *.Dockerfile, Containerfile
34
- - Common locations: scope root, docker/, build/, .docker/ directories
37
+ - Common locations: root (""), docker/, build/, .docker/ directories
35
38
  - Once found, use read_file to get the contents
36
39
 
37
40
  For each Dockerfile found, check for:
@@ -51,11 +54,12 @@ class SupplyChainSecuritySignature(dspy.Signature):
51
54
  - description: ≤25 words, imperative tone, no filler ("Fix X", "Pin Y").
52
55
  - No polite or conversational language ("I suggest", "Please consider", "Great").
53
56
  - Do not populate code_snippet—use line numbers instead.
57
+ - File paths in issues must be relative to the scope root.
54
58
 
55
59
  ## 2. DEPENDENCY SECURITY (package manifests)
56
60
 
57
61
  If manifest info is provided:
58
- 1. Use read_file to read the manifest file at manifest_path
62
+ 1. Use read_file to read the manifest file at manifest_path (relative to scope root)
59
63
  2. Extract ALL dependencies with their names and versions
60
64
  3. Use OSV tools to scan dependencies for vulnerabilities
61
65
  4. Only report vulnerabilities actually found by OSV queries
@@ -104,14 +108,11 @@ class SupplyChainSecuritySignature(dspy.Signature):
104
108
  - CWE ID if applicable
105
109
  """
106
110
 
107
- scope_subroot: str = dspy.InputField(
108
- desc="Path to scope root relative to repo root. Search here for Dockerfiles."
109
- )
110
111
  manifest_path: str = dspy.InputField(
111
- desc="Path to manifest file (e.g., package.json, go.mod). Empty string if none."
112
+ desc="Path to manifest file relative to scope root (e.g., package.json, go.mod). Empty string if none."
112
113
  )
113
114
  lock_file_path: str = dspy.InputField(
114
- desc="Path to lock file (e.g., package-lock.json, go.sum). Empty string if none."
115
+ desc="Path to lock file relative to scope root (e.g., package-lock.json, go.sum). Empty string if none."
115
116
  )
116
117
  package_manager: str = dspy.InputField(
117
118
  desc="Package manager name (e.g., npm, pip, go). Empty string if no manifest."
@@ -121,14 +122,18 @@ class SupplyChainSecuritySignature(dspy.Signature):
121
122
  )
122
123
 
123
124
  issues: list[Issue] = dspy.OutputField(
124
- desc="Verified supply chain issues only. Titles <10 words. Descriptions ≤25 words, imperative. Empty list if none."
125
+ desc="Verified supply chain issues only. Titles <10 words. Descriptions ≤25 words, imperative. "
126
+ "File paths must be relative to scope root. Empty list if none."
125
127
  )
126
128
 
127
129
 
128
130
  class SupplyChainAuditor(dspy.Module):
129
- """Analyzes supply chain security (Dockerfiles and dependencies) using DSPy."""
131
+ """Analyzes supply chain security (Dockerfiles and dependencies) using DSPy.
130
132
 
131
- category = IssueCategory.SECURITY
133
+ MCP tools are scope-restricted: for each scope, filesystem/parser tools are
134
+ rooted at repo_path/scope.subroot so the agent cannot access files outside
135
+ the scope. OSV tools (no filesystem root) are shared across scopes.
136
+ """
132
137
 
133
138
  def __init__(self) -> None:
134
139
  """Initialize the security auditor."""
@@ -142,22 +147,30 @@ class SupplyChainAuditor(dspy.Module):
142
147
  Returns True if any scope has dependency changes or Dockerfile modifications.
143
148
  Avoids spinning up MCP servers when there's nothing to audit.
144
149
  """
150
+ return any(self._scope_needs_analysis(s) for s in scopes)
151
+
152
+ def _scope_needs_analysis(self, scope: ScopeResult) -> bool:
153
+ """Check if a specific scope has supply-chain-relevant changes.
154
+
155
+ Returns True if the scope has dependency changes or Dockerfile modifications.
156
+ """
145
157
  scan_unchanged = self._settings.get_scan_unchanged("supply_chain")
146
- for scope in scopes:
147
- if scope.package_manifest:
148
- if scan_unchanged or scope.package_manifest.dependencies_changed:
149
- return True
150
- for cf in scope.changed_files:
151
- fname = cf.filename.rsplit("/", 1)[-1].lower()
152
- if "dockerfile" in fname or fname == "containerfile":
153
- return True
158
+ if scope.package_manifest:
159
+ if scan_unchanged or scope.package_manifest.dependencies_changed:
160
+ return True
161
+ for cf in scope.changed_files:
162
+ fname = cf.filename.rsplit("/", 1)[-1].lower()
163
+ if "dockerfile" in fname or fname == "containerfile":
164
+ return True
154
165
  return False
155
166
 
156
- async def _create_mcp_tools(self, repo_path: Path) -> tuple[list[Any], list[Any]]:
157
- """Create DSPy tools from filesystem, parser, and OSV MCP servers.
167
+ async def _create_scoped_tools(
168
+ self, scope_root: Path
169
+ ) -> tuple[list[Any], list[Any]]:
170
+ """Create scope-restricted DSPy tools from filesystem and parser MCP servers.
158
171
 
159
172
  Args:
160
- repo_path: Path to the repository root
173
+ scope_root: Path to the scope root directory (repo_path / scope.subroot)
161
174
 
162
175
  Returns:
163
176
  Tuple of (tools, contexts) for cleanup
@@ -165,13 +178,13 @@ class SupplyChainAuditor(dspy.Module):
165
178
  tools: list[Any] = []
166
179
  contexts: list[Any] = []
167
180
  tools_dir = Path(__file__).parent.parent.parent.parent / "tools"
168
- repo_path_str = str(repo_path)
181
+ scope_root_str = str(scope_root)
169
182
  caller = "supply_chain_auditor"
170
183
 
171
184
  # Add filesystem tools for reading files and exploring structure
172
185
  tools.extend(await connect_mcp_server(
173
186
  tools_dir / "filesystem" / "server.py",
174
- [repo_path_str],
187
+ [scope_root_str],
175
188
  contexts,
176
189
  caller,
177
190
  ))
@@ -179,7 +192,7 @@ class SupplyChainAuditor(dspy.Module):
179
192
  # Add tree-sitter tools for parsing code structure
180
193
  tools.extend(await connect_mcp_server(
181
194
  tools_dir / "parsers" / "treesitter" / "server.py",
182
- [repo_path_str],
195
+ [scope_root_str],
183
196
  contexts,
184
197
  caller,
185
198
  ))
@@ -187,12 +200,24 @@ class SupplyChainAuditor(dspy.Module):
187
200
  # Add ripgrep tools for searching code patterns
188
201
  tools.extend(await connect_mcp_server(
189
202
  tools_dir / "parsers" / "ripgrep" / "server.py",
190
- [repo_path_str],
203
+ [scope_root_str],
191
204
  contexts,
192
205
  caller,
193
206
  ))
194
207
 
195
- # Add OSV tools for querying real vulnerability data
208
+ return tools, contexts
209
+
210
+ async def _create_osv_tools(self) -> tuple[list[Any], list[Any]]:
211
+ """Create OSV tools (no filesystem root, can be shared).
212
+
213
+ Returns:
214
+ Tuple of (tools, contexts) for cleanup
215
+ """
216
+ tools: list[Any] = []
217
+ contexts: list[Any] = []
218
+ tools_dir = Path(__file__).parent.parent.parent.parent / "tools"
219
+ caller = "supply_chain_auditor"
220
+
196
221
  tools.extend(await connect_mcp_server(
197
222
  tools_dir / "cyber" / "osv" / "server.py",
198
223
  [],
@@ -205,6 +230,11 @@ class SupplyChainAuditor(dspy.Module):
205
230
  async def aforward(self, scopes: Sequence[ScopeResult], repo_path: Path) -> list[Issue]:
206
231
  """Analyze scopes for supply chain security vulnerabilities and return issues.
207
232
 
233
+ For each scope, filesystem/parser tools are created rooted at
234
+ repo_path/scope.subroot so the agent can only access files within the
235
+ scope boundary. OSV tools are shared across scopes since they don't
236
+ access the filesystem.
237
+
208
238
  Args:
209
239
  scopes: The scopes containing changed files to analyze
210
240
  repo_path: Path to the cloned repository for reading manifest files
@@ -223,30 +253,53 @@ class SupplyChainAuditor(dspy.Module):
223
253
  return []
224
254
 
225
255
  all_issues: list[Issue] = []
226
- tools, contexts = await self._create_mcp_tools(repo_path)
227
-
228
256
  supply_chain_max_iters = self._settings.get_max_iters("supply_chain")
229
- supply_chain_agent = dspy.ReAct(
230
- signature=SupplyChainSecuritySignature,
231
- tools=tools,
232
- max_iters=supply_chain_max_iters,
233
- )
257
+
258
+ # Create OSV tools once (shared across scopes, no filesystem root)
259
+ osv_tools, osv_contexts = await self._create_osv_tools()
234
260
 
235
261
  try:
236
262
  for scope in scopes:
263
+ # Skip scopes with no supply-chain-relevant changes
264
+ if not self._scope_needs_analysis(scope):
265
+ continue
266
+
237
267
  # Check if we should scan unchanged manifests
238
268
  scan_unchanged = self._settings.get_scan_unchanged("supply_chain")
239
269
 
240
270
  # Get manifest info (skip if unchanged and scan_unchanged=False)
241
271
  manifest = scope.package_manifest
242
272
  should_scan_manifest = manifest and (scan_unchanged or manifest.dependencies_changed)
243
- manifest_path = manifest.manifest_path if should_scan_manifest else ""
244
- lock_file_path = (manifest.lock_file_path or "") if should_scan_manifest else ""
245
- package_manager = manifest.package_manager if should_scan_manifest else ""
246
- if manifest and not should_scan_manifest:
247
- logger.debug(f"Skipping unchanged manifest: {manifest.manifest_path}")
248
- # Run supply chain analysis — agent will search for Dockerfiles itself
273
+
274
+ # Convert manifest paths to scope-relative
275
+ if should_scan_manifest:
276
+ manifest_path = strip_prefix(manifest.manifest_path, scope.subroot)
277
+ lock_file_path = (
278
+ strip_prefix(manifest.lock_file_path, scope.subroot)
279
+ if manifest.lock_file_path
280
+ else ""
281
+ )
282
+ package_manager = manifest.package_manager
283
+ else:
284
+ manifest_path = ""
285
+ lock_file_path = ""
286
+ package_manager = ""
287
+ if manifest:
288
+ logger.debug(f"Skipping unchanged manifest: {manifest.manifest_path}")
289
+
290
+ # Scope-restrict filesystem/parser tools to the scope's subroot
291
+ scope_root = resolve_scope_root(repo_path, scope.subroot)
292
+ scoped_tools, scoped_contexts = await self._create_scoped_tools(scope_root)
293
+
249
294
  try:
295
+ # Combine scoped filesystem tools with shared OSV tools
296
+ all_tools = scoped_tools + osv_tools
297
+ supply_chain_agent = dspy.ReAct(
298
+ signature=SupplyChainSecuritySignature,
299
+ tools=all_tools,
300
+ max_iters=supply_chain_max_iters,
301
+ )
302
+
250
303
  logger.debug(
251
304
  f"Analyzing supply chain in scope {scope.subroot}: "
252
305
  f"manifest={bool(manifest_path)}"
@@ -254,22 +307,26 @@ class SupplyChainAuditor(dspy.Module):
254
307
  # Track supply_chain signature costs separately
255
308
  async with SignatureContext("supply_chain", self._cost_tracker):
256
309
  result = await supply_chain_agent.acall(
257
- scope_subroot=scope.subroot,
258
310
  manifest_path=manifest_path,
259
311
  lock_file_path=lock_file_path,
260
312
  package_manager=package_manager,
261
- category=self.category,
313
+ category=IssueCategory.SECURITY,
262
314
  )
263
315
  issues = [
264
316
  issue for issue in result.issues
265
317
  if issue.confidence >= MIN_CONFIDENCE
266
318
  ]
319
+ # Restore repo-root-relative paths in reported issues
320
+ restore_repo_paths(issues, scope.subroot)
267
321
  all_issues.extend(issues)
268
322
  logger.debug(f" Supply chain security in scope {scope.subroot}: {len(issues)} issues")
269
323
  except Exception as e:
270
324
  logger.error(f"Error analyzing supply chain in scope {scope.subroot}: {e}")
325
+ finally:
326
+ await cleanup_mcp_contexts(scoped_contexts)
271
327
  finally:
272
- await cleanup_mcp_contexts(contexts)
328
+ await cleanup_mcp_contexts(osv_contexts)
329
+
273
330
  logger.info(f"Security audit found {len(all_issues)} issues")
274
331
  return all_issues
275
332
 
@@ -1,65 +0,0 @@
1
- """Helper functions for DSPy review modules."""
2
-
3
- import logging
4
- import os
5
-
6
- from codespy.tools.git.models import ChangedFile
7
- from codespy.agents.reviewer.models import Issue
8
-
9
- logger = logging.getLogger(__name__)
10
-
11
-
12
- # Language detection based on file extension
13
- EXTENSION_TO_LANGUAGE = {
14
- "py": "Python",
15
- "js": "JavaScript",
16
- "ts": "TypeScript",
17
- "jsx": "JavaScript (React)",
18
- "tsx": "TypeScript (React)",
19
- "go": "Go",
20
- "rs": "Rust",
21
- "java": "Java",
22
- "kt": "Kotlin",
23
- "c": "C",
24
- "cpp": "C++",
25
- "h": "C/C++ Header",
26
- "hpp": "C++ Header",
27
- "cs": "C#",
28
- "rb": "Ruby",
29
- "php": "PHP",
30
- "swift": "Swift",
31
- "scala": "Scala",
32
- "sh": "Shell",
33
- "bash": "Bash",
34
- "sql": "SQL",
35
- "vue": "Vue",
36
- "svelte": "Svelte",
37
- }
38
-
39
- # Minimum confidence threshold
40
- MIN_CONFIDENCE = 0.5
41
-
42
- # Markdown file extensions to review
43
- MARKDOWN_EXTENSIONS = {".md", ".markdown", ".mdx", ".rst", ".txt"}
44
-
45
-
46
- def is_markdown_file(filename: str) -> bool:
47
- """Check if the file is a markdown documentation file."""
48
- _, ext = os.path.splitext(filename.lower())
49
- return ext in MARKDOWN_EXTENSIONS
50
-
51
-
52
- def get_language(file: ChangedFile) -> str:
53
- """Get the programming language for a file based on extension.
54
-
55
- Args:
56
- file: The changed file
57
-
58
- Returns:
59
- Language name or "Unknown"
60
- """
61
- return EXTENSION_TO_LANGUAGE.get(file.extension, "Unknown")
62
-
63
-
64
-
65
-
File without changes