mycode-coding-agent 0.1.0__py3-none-any.whl

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 (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
mycode/tools/glob.py ADDED
@@ -0,0 +1,247 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from mycode.permissions import PermissionChecker, PermissionDecision, PermissionRequest
6
+ from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
7
+ from mycode.tools.bounds import clamp_positive_int_upper_bound
8
+ from mycode.tools.ignore import is_ignored_path, is_low_relevance_path
9
+ from mycode.tools.path_permissions import PathPermissionPolicy
10
+ from mycode.tools.patterns import (
11
+ is_explicit_path_pattern,
12
+ is_sensitive_path_pattern,
13
+ validate_relative_pattern,
14
+ )
15
+ from mycode.tools.workspace import Workspace, WorkspacePathError
16
+
17
+
18
+ DEFAULT_MAX_RESULTS = 100
19
+ MAX_RESULTS_LIMIT = 1000
20
+
21
+
22
+ class GlobArgs(ToolArgs):
23
+ pattern: str = Field(
24
+ min_length=1,
25
+ description=(
26
+ "用于匹配 workspace 内文件路径的相对 glob pattern,例如 **/*.py。"
27
+ ),
28
+ )
29
+ max_results: int = Field(
30
+ default=DEFAULT_MAX_RESULTS,
31
+ ge=1,
32
+ le=MAX_RESULTS_LIMIT,
33
+ strict=True,
34
+ )
35
+
36
+ @field_validator("max_results", mode="before")
37
+ @classmethod
38
+ def clamp_max_results(cls, value: object) -> object:
39
+ return clamp_positive_int_upper_bound(
40
+ value,
41
+ upper_bound=MAX_RESULTS_LIMIT,
42
+ )
43
+
44
+
45
+ class GlobTool(PydanticTool[GlobArgs]):
46
+ name = "glob"
47
+ description = "按 glob pattern 查找 workspace 内文件。"
48
+ args_model = GlobArgs
49
+ capability = "read"
50
+ risk = "low"
51
+ concurrency_safe = True
52
+
53
+ def __init__(self, workspace: Workspace) -> None:
54
+ self.workspace = workspace
55
+
56
+ def build_permission_request(self, args: GlobArgs) -> PermissionRequest:
57
+ return PermissionRequest(
58
+ tool_name=self.name,
59
+ capability=self.capability,
60
+ action=self.name,
61
+ target=args.pattern,
62
+ arguments=args.model_dump(),
63
+ )
64
+
65
+ def check_permission(
66
+ self,
67
+ args: GlobArgs,
68
+ permission_checker: PermissionChecker,
69
+ ) -> tuple[PermissionRequest, PermissionDecision]:
70
+ request = self.build_permission_request(args)
71
+ pattern_error = validate_relative_pattern(
72
+ args.pattern,
73
+ label="Glob pattern",
74
+ )
75
+ if pattern_error is not None:
76
+ return request, PermissionDecision.deny(
77
+ reason="outside_workspace",
78
+ message=pattern_error,
79
+ metadata={"pattern": args.pattern},
80
+ )
81
+
82
+ if is_explicit_path_pattern(args.pattern):
83
+ path_decision = PathPermissionPolicy(self.workspace).check_path(
84
+ request,
85
+ args.pattern,
86
+ )
87
+ if path_decision.status != "allow":
88
+ return request, path_decision
89
+
90
+ decision = permission_checker.check(
91
+ request,
92
+ self.get_permission_profile(),
93
+ )
94
+ if decision.status != "allow":
95
+ return request, decision
96
+
97
+ return request, PermissionDecision.allow(
98
+ message=decision.message,
99
+ metadata={**decision.metadata, **path_decision.metadata},
100
+ )
101
+
102
+ if is_sensitive_path_pattern(args.pattern):
103
+ return request, PermissionDecision.ask(
104
+ reason="sensitive_path",
105
+ message=f"Sensitive path pattern requires confirmation: {args.pattern}",
106
+ metadata={
107
+ "pattern": args.pattern,
108
+ "pattern_scope": "sensitive_pattern",
109
+ },
110
+ )
111
+
112
+ return request, permission_checker.check(
113
+ request,
114
+ self.get_permission_profile(),
115
+ )
116
+
117
+ def run_authorized(
118
+ self,
119
+ args: GlobArgs,
120
+ decision: PermissionDecision,
121
+ ) -> ToolResult:
122
+ return self._run_with_options(
123
+ args,
124
+ include_ignored=_can_include_ignored(decision),
125
+ include_low_relevance=is_explicit_path_pattern(args.pattern),
126
+ )
127
+
128
+ def _run(self, args: GlobArgs) -> ToolResult:
129
+ return self._run_with_options(
130
+ args,
131
+ include_ignored=False,
132
+ include_low_relevance=is_explicit_path_pattern(args.pattern),
133
+ )
134
+
135
+ def _run_with_options(
136
+ self,
137
+ args: GlobArgs,
138
+ *,
139
+ include_ignored: bool,
140
+ include_low_relevance: bool,
141
+ ) -> ToolResult:
142
+ pattern_error = validate_relative_pattern(
143
+ args.pattern,
144
+ label="Glob pattern",
145
+ )
146
+ if pattern_error is not None:
147
+ return ToolResult.failure(
148
+ error=pattern_error,
149
+ metadata={"pattern": args.pattern},
150
+ )
151
+
152
+ try:
153
+ result = _find_file_matches(
154
+ self.workspace,
155
+ args.pattern,
156
+ include_ignored=include_ignored,
157
+ include_low_relevance=include_low_relevance,
158
+ )
159
+ except ValueError as error:
160
+ return ToolResult.failure(
161
+ error=f"Invalid glob pattern: {args.pattern}",
162
+ metadata={"pattern": args.pattern, "reason": str(error)},
163
+ )
164
+
165
+ matches = result.matches
166
+ selected_matches = matches[: args.max_results]
167
+ content = "\n".join(selected_matches)
168
+ if not content:
169
+ content = "No files matched."
170
+ if result.filtered_count > 0:
171
+ content += " Some matches were filtered by workspace relevance or safety rules."
172
+
173
+ return ToolResult.success(
174
+ content=content,
175
+ metadata={
176
+ "pattern": args.pattern,
177
+ "result_count": len(selected_matches),
178
+ "total_matches": len(matches),
179
+ "filtered_count": result.filtered_count,
180
+ "filtered_reasons": result.filtered_reasons,
181
+ "max_results": args.max_results,
182
+ "truncated": len(matches) > args.max_results,
183
+ },
184
+ )
185
+
186
+
187
+ class GlobMatchResult:
188
+ def __init__(
189
+ self,
190
+ matches: list[str],
191
+ filtered_reasons: dict[str, int],
192
+ ) -> None:
193
+ self.matches = matches
194
+ self.filtered_reasons = filtered_reasons
195
+
196
+ @property
197
+ def filtered_count(self) -> int:
198
+ return sum(self.filtered_reasons.values())
199
+
200
+
201
+ def _find_file_matches(
202
+ workspace: Workspace,
203
+ pattern: str,
204
+ *,
205
+ include_ignored: bool = False,
206
+ include_low_relevance: bool = False,
207
+ ) -> GlobMatchResult:
208
+ matches: list[str] = []
209
+ filtered_reasons: dict[str, int] = {}
210
+
211
+ for candidate in workspace.root.glob(pattern):
212
+ try:
213
+ workspace.resolve_path(candidate)
214
+ if not candidate.is_file():
215
+ continue
216
+ if not include_ignored and is_ignored_path(candidate, workspace.root):
217
+ _increment_reason(filtered_reasons, "ignored")
218
+ continue
219
+ if (
220
+ not include_low_relevance
221
+ and is_low_relevance_path(candidate, workspace.root)
222
+ ):
223
+ _increment_reason(filtered_reasons, "low_relevance")
224
+ continue
225
+ except (OSError, WorkspacePathError):
226
+ continue
227
+
228
+ matches.append(candidate.relative_to(workspace.root).as_posix())
229
+
230
+ return GlobMatchResult(
231
+ matches=sorted(matches),
232
+ filtered_reasons=filtered_reasons,
233
+ )
234
+
235
+
236
+ def _increment_reason(reasons: dict[str, int], reason: str) -> None:
237
+ reasons[reason] = reasons.get(reason, 0) + 1
238
+
239
+
240
+ def _can_include_ignored(decision: PermissionDecision) -> bool:
241
+ return (
242
+ decision.metadata.get("confirmation_status") == "approved"
243
+ and (
244
+ decision.metadata.get("path_scope") in {"ignored_path", "sensitive_path"}
245
+ or decision.metadata.get("pattern_scope") == "sensitive_pattern"
246
+ )
247
+ )
mycode/tools/grep.py ADDED
@@ -0,0 +1,324 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ from pydantic import Field, field_validator
5
+
6
+ from mycode.permissions import PermissionChecker, PermissionDecision, PermissionRequest
7
+ from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
8
+ from mycode.tools.bounds import clamp_positive_int_upper_bound
9
+ from mycode.tools.ignore import is_ignored_path, is_low_relevance_path
10
+ from mycode.tools.path_permissions import PathPermissionPolicy
11
+ from mycode.tools.patterns import (
12
+ is_explicit_path_pattern,
13
+ is_sensitive_path_pattern,
14
+ validate_relative_pattern,
15
+ )
16
+ from mycode.tools.text import contains_nul_byte, decode_text
17
+ from mycode.tools.workspace import Workspace, WorkspacePathError
18
+
19
+
20
+ DEFAULT_PATH_PATTERN = "**/*"
21
+ DEFAULT_MAX_RESULTS = 100
22
+ MAX_RESULTS_LIMIT = 1000
23
+ MAX_MATCH_LINE_CHARS = 240
24
+
25
+
26
+ class GrepArgs(ToolArgs):
27
+ query: str = Field(
28
+ min_length=1,
29
+ description="用于匹配文件内容的正则表达式。",
30
+ )
31
+ path_pattern: str = Field(
32
+ default=DEFAULT_PATH_PATTERN,
33
+ min_length=1,
34
+ description=(
35
+ "限定搜索文件范围的 workspace 相对 glob pattern,例如 **/*.py。"
36
+ ),
37
+ )
38
+ case_sensitive: bool = False
39
+ max_results: int = Field(
40
+ default=DEFAULT_MAX_RESULTS,
41
+ ge=1,
42
+ le=MAX_RESULTS_LIMIT,
43
+ strict=True,
44
+ )
45
+
46
+ @field_validator("max_results", mode="before")
47
+ @classmethod
48
+ def clamp_max_results(cls, value: object) -> object:
49
+ return clamp_positive_int_upper_bound(
50
+ value,
51
+ upper_bound=MAX_RESULTS_LIMIT,
52
+ )
53
+
54
+
55
+ class GrepTool(PydanticTool[GrepArgs]):
56
+ name = "grep"
57
+ description = "使用正则表达式搜索 workspace 文件内容。"
58
+ args_model = GrepArgs
59
+ capability = "read"
60
+ risk = "low"
61
+ concurrency_safe = True
62
+
63
+ def __init__(self, workspace: Workspace) -> None:
64
+ self.workspace = workspace
65
+
66
+ def build_permission_request(self, args: GrepArgs) -> PermissionRequest:
67
+ return PermissionRequest(
68
+ tool_name=self.name,
69
+ capability=self.capability,
70
+ action=self.name,
71
+ target=args.path_pattern,
72
+ arguments=args.model_dump(),
73
+ )
74
+
75
+ def check_permission(
76
+ self,
77
+ args: GrepArgs,
78
+ permission_checker: PermissionChecker,
79
+ ) -> tuple[PermissionRequest, PermissionDecision]:
80
+ request = self.build_permission_request(args)
81
+ pattern_error = validate_relative_pattern(
82
+ args.path_pattern,
83
+ label="Path pattern",
84
+ )
85
+ if pattern_error is not None:
86
+ return request, PermissionDecision.deny(
87
+ reason="outside_workspace",
88
+ message=pattern_error,
89
+ metadata={"path_pattern": args.path_pattern},
90
+ )
91
+
92
+ if is_explicit_path_pattern(args.path_pattern):
93
+ path_decision = PathPermissionPolicy(self.workspace).check_path(
94
+ request,
95
+ args.path_pattern,
96
+ )
97
+ if path_decision.status != "allow":
98
+ return request, path_decision
99
+
100
+ decision = permission_checker.check(
101
+ request,
102
+ self.get_permission_profile(),
103
+ )
104
+ if decision.status != "allow":
105
+ return request, decision
106
+
107
+ return request, PermissionDecision.allow(
108
+ message=decision.message,
109
+ metadata={**decision.metadata, **path_decision.metadata},
110
+ )
111
+
112
+ if is_sensitive_path_pattern(args.path_pattern):
113
+ return request, PermissionDecision.ask(
114
+ reason="sensitive_path",
115
+ message=(
116
+ "Sensitive path pattern requires confirmation: "
117
+ f"{args.path_pattern}"
118
+ ),
119
+ metadata={
120
+ "path_pattern": args.path_pattern,
121
+ "pattern_scope": "sensitive_pattern",
122
+ },
123
+ )
124
+
125
+ return request, permission_checker.check(
126
+ request,
127
+ self.get_permission_profile(),
128
+ )
129
+
130
+ def run_authorized(
131
+ self,
132
+ args: GrepArgs,
133
+ decision: PermissionDecision,
134
+ ) -> ToolResult:
135
+ return self._run_with_options(
136
+ args,
137
+ include_ignored=_can_include_ignored(decision),
138
+ include_low_relevance=is_explicit_path_pattern(args.path_pattern),
139
+ )
140
+
141
+ def _run(self, args: GrepArgs) -> ToolResult:
142
+ return self._run_with_options(
143
+ args,
144
+ include_ignored=False,
145
+ include_low_relevance=is_explicit_path_pattern(args.path_pattern),
146
+ )
147
+
148
+ def _run_with_options(
149
+ self,
150
+ args: GrepArgs,
151
+ *,
152
+ include_ignored: bool,
153
+ include_low_relevance: bool,
154
+ ) -> ToolResult:
155
+ pattern_error = validate_relative_pattern(
156
+ args.path_pattern,
157
+ label="Path pattern",
158
+ )
159
+ if pattern_error is not None:
160
+ return ToolResult.failure(
161
+ error=pattern_error,
162
+ metadata={"path_pattern": args.path_pattern},
163
+ )
164
+
165
+ flags = 0 if args.case_sensitive else re.IGNORECASE
166
+ try:
167
+ query_pattern = re.compile(args.query, flags=flags)
168
+ except re.error as error:
169
+ return ToolResult.failure(
170
+ error=f"Invalid regular expression: {error}",
171
+ metadata={"query": args.query, "reason": str(error)},
172
+ )
173
+
174
+ try:
175
+ search_result = _find_search_files(
176
+ self.workspace,
177
+ args.path_pattern,
178
+ include_ignored=include_ignored,
179
+ include_low_relevance=include_low_relevance,
180
+ )
181
+ except ValueError as error:
182
+ return ToolResult.failure(
183
+ error=f"Invalid path pattern: {args.path_pattern}",
184
+ metadata={"path_pattern": args.path_pattern, "reason": str(error)},
185
+ )
186
+
187
+ files = search_result.files
188
+ matches: list[str] = []
189
+ searched_files = 0
190
+ skipped_files = 0
191
+ truncated = False
192
+
193
+ for path in files:
194
+ raw_content = _read_bytes(path)
195
+ if raw_content is None or contains_nul_byte(raw_content):
196
+ skipped_files += 1
197
+ continue
198
+
199
+ decoded = decode_text(raw_content)
200
+ if decoded is None:
201
+ skipped_files += 1
202
+ continue
203
+
204
+ text, _encoding = decoded
205
+ searched_files += 1
206
+ relative_path = path.relative_to(self.workspace.root).as_posix()
207
+
208
+ for line_number, line in enumerate(text.splitlines(), start=1):
209
+ if not _line_matches(line, query_pattern):
210
+ continue
211
+
212
+ if len(matches) >= args.max_results:
213
+ truncated = True
214
+ break
215
+
216
+ matches.append(_format_match(relative_path, line_number, line))
217
+
218
+ if truncated:
219
+ break
220
+
221
+ content = "\n".join(matches)
222
+ if not content:
223
+ content = "No matches found."
224
+
225
+ return ToolResult.success(
226
+ content=content,
227
+ metadata={
228
+ "query": args.query,
229
+ "path_pattern": args.path_pattern,
230
+ "case_sensitive": args.case_sensitive,
231
+ "result_count": len(matches),
232
+ "max_results": args.max_results,
233
+ "truncated": truncated,
234
+ "searched_files": searched_files,
235
+ "skipped_files": skipped_files,
236
+ "filtered_count": search_result.filtered_count,
237
+ "filtered_reasons": search_result.filtered_reasons,
238
+ },
239
+ )
240
+
241
+
242
+ class GrepSearchResult:
243
+ def __init__(
244
+ self,
245
+ files: list[Path],
246
+ filtered_reasons: dict[str, int],
247
+ ) -> None:
248
+ self.files = files
249
+ self.filtered_reasons = filtered_reasons
250
+
251
+ @property
252
+ def filtered_count(self) -> int:
253
+ return sum(self.filtered_reasons.values())
254
+
255
+
256
+ def _find_search_files(
257
+ workspace: Workspace,
258
+ path_pattern: str,
259
+ *,
260
+ include_ignored: bool = False,
261
+ include_low_relevance: bool = False,
262
+ ) -> GrepSearchResult:
263
+ files: list[Path] = []
264
+ filtered_reasons: dict[str, int] = {}
265
+
266
+ for candidate in workspace.root.glob(path_pattern):
267
+ try:
268
+ workspace.resolve_path(candidate)
269
+ if not candidate.is_file():
270
+ continue
271
+ if not include_ignored and is_ignored_path(candidate, workspace.root):
272
+ _increment_reason(filtered_reasons, "ignored")
273
+ continue
274
+ if (
275
+ not include_low_relevance
276
+ and is_low_relevance_path(candidate, workspace.root)
277
+ ):
278
+ _increment_reason(filtered_reasons, "low_relevance")
279
+ continue
280
+ except (OSError, WorkspacePathError):
281
+ continue
282
+
283
+ files.append(candidate)
284
+
285
+ return GrepSearchResult(
286
+ files=sorted(files),
287
+ filtered_reasons=filtered_reasons,
288
+ )
289
+
290
+
291
+ def _increment_reason(reasons: dict[str, int], reason: str) -> None:
292
+ reasons[reason] = reasons.get(reason, 0) + 1
293
+
294
+
295
+ def _can_include_ignored(decision: PermissionDecision) -> bool:
296
+ return (
297
+ decision.metadata.get("confirmation_status") == "approved"
298
+ and (
299
+ decision.metadata.get("path_scope") in {"ignored_path", "sensitive_path"}
300
+ or decision.metadata.get("pattern_scope") == "sensitive_pattern"
301
+ )
302
+ )
303
+
304
+
305
+ def _read_bytes(path: Path) -> bytes | None:
306
+ try:
307
+ return path.read_bytes()
308
+ except OSError:
309
+ return None
310
+
311
+
312
+ def _line_matches(line: str, query_pattern: re.Pattern[str]) -> bool:
313
+ return query_pattern.search(line) is not None
314
+
315
+
316
+ def _format_match(path: str, line_number: int, line: str) -> str:
317
+ return f"{path}:{line_number} | {_truncate_line(line)}"
318
+
319
+
320
+ def _truncate_line(line: str) -> str:
321
+ if len(line) <= MAX_MATCH_LINE_CHARS:
322
+ return line
323
+
324
+ return f"{line[: MAX_MATCH_LINE_CHARS - 3]}..."
mycode/tools/ignore.py ADDED
@@ -0,0 +1,122 @@
1
+ from pathlib import Path
2
+
3
+
4
+ DEFAULT_EXCLUDED_DIR_NAMES = frozenset(
5
+ {
6
+ ".git",
7
+ ".mypy_cache",
8
+ ".pytest_cache",
9
+ ".ruff_cache",
10
+ ".venv",
11
+ "__pycache__",
12
+ "build",
13
+ "dist",
14
+ "node_modules",
15
+ "venv",
16
+ }
17
+ )
18
+
19
+ DEFAULT_LOW_RELEVANCE_DIR_NAMES = frozenset(
20
+ {
21
+ "__fixtures__",
22
+ "__snapshots__",
23
+ "archive",
24
+ "archives",
25
+ "demo",
26
+ "demos",
27
+ "example",
28
+ "examples",
29
+ "external",
30
+ "fixture",
31
+ "fixtures",
32
+ "reference",
33
+ "references",
34
+ "sample",
35
+ "samples",
36
+ "snapshot",
37
+ "snapshots",
38
+ "third_party",
39
+ "vendor",
40
+ }
41
+ )
42
+
43
+ DEFAULT_SENSITIVE_FILE_NAMES = frozenset(
44
+ {
45
+ ".env",
46
+ ".envrc",
47
+ ".npmrc",
48
+ ".pypirc",
49
+ "id_dsa",
50
+ "id_ecdsa",
51
+ "id_ed25519",
52
+ "id_rsa",
53
+ }
54
+ )
55
+
56
+ DEFAULT_SENSITIVE_FILE_SUFFIXES = (
57
+ ".key",
58
+ ".p12",
59
+ ".pem",
60
+ ".pfx",
61
+ )
62
+
63
+ DEFAULT_SAFE_ENV_TEMPLATE_FILE_NAMES = frozenset(
64
+ {
65
+ ".env.example",
66
+ ".env.sample",
67
+ ".env.template",
68
+ }
69
+ )
70
+
71
+
72
+ def is_ignored_path(
73
+ path: Path,
74
+ root: Path,
75
+ excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES,
76
+ ) -> bool:
77
+ try:
78
+ relative_path = path.relative_to(root)
79
+ except ValueError:
80
+ return True
81
+
82
+ if any(part in excluded_dir_names for part in relative_path.parts[:-1]):
83
+ return True
84
+
85
+ return is_sensitive_path(path, root)
86
+
87
+
88
+ def is_low_relevance_path(
89
+ path: Path,
90
+ root: Path,
91
+ low_relevance_dir_names: frozenset[str] = DEFAULT_LOW_RELEVANCE_DIR_NAMES,
92
+ ) -> bool:
93
+ try:
94
+ relative_path = path.relative_to(root)
95
+ except ValueError:
96
+ return True
97
+
98
+ return any(part.casefold() in low_relevance_dir_names for part in relative_path.parts[:-1])
99
+
100
+
101
+ def is_sensitive_path(
102
+ path: Path,
103
+ root: Path,
104
+ sensitive_file_names: frozenset[str] = DEFAULT_SENSITIVE_FILE_NAMES,
105
+ sensitive_file_suffixes: tuple[str, ...] = DEFAULT_SENSITIVE_FILE_SUFFIXES,
106
+ ) -> bool:
107
+ try:
108
+ path.relative_to(root)
109
+ except ValueError:
110
+ return True
111
+
112
+ name = path.name.casefold()
113
+ if name in DEFAULT_SAFE_ENV_TEMPLATE_FILE_NAMES:
114
+ return False
115
+
116
+ if name in sensitive_file_names:
117
+ return True
118
+
119
+ if name.startswith(".env."):
120
+ return True
121
+
122
+ return any(name.endswith(suffix) for suffix in sensitive_file_suffixes)