flash-context 0.2.0__tar.gz → 0.3.1__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.
- flash_context-0.3.1/CHANGELOG.md +87 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/PKG-INFO +21 -4
- {flash_context-0.2.0 → flash_context-0.3.1}/README.md +20 -3
- {flash_context-0.2.0 → flash_context-0.3.1}/pyproject.toml +3 -2
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/agent.py +167 -76
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/cli.py +15 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/logging.py +13 -10
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/mcp_server.py +10 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/tools/safety.py +25 -1
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/types.py +0 -2
- {flash_context-0.2.0 → flash_context-0.3.1}/.env.example +0 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/.gitignore +0 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/LICENSE +0 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/config.yaml.example +0 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/__init__.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/config.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/llm.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/tools/__init__.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/tools/glob.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/tools/grep.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.1}/src/flash_context/tools/read.py +0 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
User-visible changes and migration instructions for each release.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.3.1] - 2026-11-01
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Report the flash-context package version during MCP initialization instead of
|
|
12
|
+
the MCP SDK version.
|
|
13
|
+
- Reuse repeated tool observations without discarding fresh searches in the same
|
|
14
|
+
batch; execute same-turn duplicates only once and stop all-repeat stalls.
|
|
15
|
+
- Continue implementation searches after documentation-only citations within the
|
|
16
|
+
existing turn budget. Documentation-only fallbacks retain citations and snippets
|
|
17
|
+
with explicit incomplete-result metadata and warnings.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Describe deterministic lookup and delegated model search together in the README.
|
|
22
|
+
|
|
23
|
+
### Migration
|
|
24
|
+
|
|
25
|
+
- Implementation questions may use additional turns before returning documentation
|
|
26
|
+
evidence. These fallbacks set `truncated: true`; partial cited CLI results still
|
|
27
|
+
exit 0. Budgets, configuration keys, and result schemas are unchanged.
|
|
28
|
+
|
|
29
|
+
## [0.3.0] - 2026-11-01
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Resolve bare file-path queries locally with bounded previews and find unique
|
|
34
|
+
Python definitions even when many references precede them.
|
|
35
|
+
- Keep literal and scoped searches from scanning unrelated directory trees while
|
|
36
|
+
preserving safety checks and existing resource limits.
|
|
37
|
+
- Surface scan failures from the initial file sample and CLI result warnings.
|
|
38
|
+
- Describe the MCP exploration tool during discovery and use tool-specific
|
|
39
|
+
trajectory counters instead of misleading empty read-content counts.
|
|
40
|
+
|
|
41
|
+
### Migration
|
|
42
|
+
|
|
43
|
+
- Citation objects no longer include the unused `reason` field. Consumers should
|
|
44
|
+
read `path`, `start_line`, and `end_line` without expecting `reason: null`.
|
|
45
|
+
- The CLI now exits 6 for empty results after scan-budget exhaustion or a tool
|
|
46
|
+
processing timeout, retaining text/JSON on stdout and warnings on stderr.
|
|
47
|
+
Partial cited results and complete empty searches still exit 0.
|
|
48
|
+
- Trajectory tool entries now include only their relevant counter:
|
|
49
|
+
`content_length` (read), `hit_count` (grep), or `match_count` (glob).
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- Prompt the explorer to prefer implementation source for coding questions and
|
|
54
|
+
the smallest supporting citation ranges. Validation checks safe line coverage;
|
|
55
|
+
precision and relevance remain model-dependent.
|
|
56
|
+
- Publish GitHub releases automatically after PyPI installation verification,
|
|
57
|
+
using this changelog and the same distribution artifacts uploaded to PyPI.
|
|
58
|
+
- Refresh package-index metadata during release installation retries.
|
|
59
|
+
|
|
60
|
+
## [0.2.0] - 2026-11-01
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- Enforce deny/ignore rules on requested and resolved paths, including environment
|
|
65
|
+
files, private-key names, and runtime histories. Prune denied directories.
|
|
66
|
+
- Bound scan bytes, directory entries, processing time, regex execution, tool
|
|
67
|
+
batches, and model observations; report incomplete coverage explicitly.
|
|
68
|
+
- Improve exact-lookup routing, read ranges, truncation metadata, and structured
|
|
69
|
+
endpoint/filesystem failures across CLI and MCP.
|
|
70
|
+
- Keep optional trajectory logs metadata-only and outside the inspected root.
|
|
71
|
+
|
|
72
|
+
### Migration
|
|
73
|
+
|
|
74
|
+
- Move any trajectory directory inside the inspected repository outside it, or
|
|
75
|
+
disable logging with `FLASH_CONTEXT_TRAJ_DIR=`. Empty/null settings disable
|
|
76
|
+
inherited logging. Existing logs remain untouched.
|
|
77
|
+
|
|
78
|
+
## [0.1.0] - 2026-11-01
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
|
|
82
|
+
- Initial Python CLI and optional MCP adapter sharing a read-only explorer core.
|
|
83
|
+
- OpenAI-compatible tool-call loop with bounded Read/Glob/Grep repository tools,
|
|
84
|
+
root-scoped path validation, validated citations, and raw source snippets.
|
|
85
|
+
- Exact path/symbol lookup shortcuts, concurrent local tools, and configurable
|
|
86
|
+
endpoint settings and limits.
|
|
87
|
+
- Wheel/source packaging and tag-driven TestPyPI/PyPI publication and smoke checks.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: flash-context
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Read-only context exploration and retrieval for AI agents, starting with codebases and repositories.
|
|
5
5
|
Project-URL: Repository, https://github.com/zyf0717/flash-context
|
|
6
6
|
Project-URL: Issues, https://github.com/zyf0717/flash-context/issues
|
|
@@ -28,8 +28,9 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
[](https://pypi.org/project/flash-context/)
|
|
29
29
|
[](LICENSE)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
A lightweight, model-agnostic repository explorer that combines deterministic
|
|
32
|
+
lookup with delegated LLM search to deliver compact, validated source evidence.
|
|
33
|
+
Available as a Python CLI and Model Context Protocol (MCP) server.
|
|
33
34
|
|
|
34
35
|
Delegate recursive, repetitive file search and reading to a utility model
|
|
35
36
|
behind an OpenAI-compatible endpoint. The calling agent receives validated
|
|
@@ -39,10 +40,17 @@ Codebases and repositories are the current focus; the same tools explore
|
|
|
39
40
|
documentation, configuration, and other local text files. CLI and MCP share one
|
|
40
41
|
exploration core with read-only tools, path safety, and resource limits.
|
|
41
42
|
Scans and regex execution are bounded; partial results report truncation.
|
|
43
|
+
Citation validation checks safe paths and existing line coverage, not semantic
|
|
44
|
+
relevance or minimal ranges. Citation precision depends on the explorer model.
|
|
45
|
+
Implementation queries continue past documentation-only citations within the turn budget; documentation-only
|
|
46
|
+
fallbacks carry incomplete-result metadata and warnings. Build outputs such as
|
|
47
|
+
`dist/` remain excluded; inspect a source checkout for implementation evidence.
|
|
42
48
|
Optional metadata-only trajectory logs must stay outside the inspected repository.
|
|
43
49
|
|
|
44
50
|
Inspired by [FastContext](https://arxiv.org/abs/2606.14066), a now-withdrawn paper
|
|
45
|
-
on
|
|
51
|
+
on separating repository exploration from task solving, using specialized models
|
|
52
|
+
to return focused file-line evidence while reducing the coding agent's context
|
|
53
|
+
and token overhead.
|
|
46
54
|
|
|
47
55
|
## Quickstart
|
|
48
56
|
|
|
@@ -73,6 +81,14 @@ lookups can resolve locally without an endpoint. See the
|
|
|
73
81
|
[CLI contract](specs/001-flash-context-explorer/contracts/cli.md) for options,
|
|
74
82
|
output schemas, and exit codes.
|
|
75
83
|
|
|
84
|
+
For example, `--query 'httpx/_client.py'` returns a bounded file preview, while
|
|
85
|
+
`--query 'BaseTransport'` returns its definition if unique within a complete
|
|
86
|
+
Python source/stub scan. Local successes report `turns_used: 0` in JSON.
|
|
87
|
+
|
|
88
|
+
Warnings go to stderr in both output formats. An empty result after scan-budget
|
|
89
|
+
exhaustion exits **6** while preserving stdout; a complete empty search exits
|
|
90
|
+
**0**. For large repositories, see [scan-budget tuning](docs/configuration.md#large-repositories).
|
|
91
|
+
|
|
76
92
|
## MCP
|
|
77
93
|
|
|
78
94
|
```bash
|
|
@@ -87,6 +103,7 @@ See [MCP integration](docs/mcp.md) for editor configuration and source launches.
|
|
|
87
103
|
- [Configuration](docs/configuration.md): endpoint settings, precedence, and limits.
|
|
88
104
|
- [Architecture and scope](docs/architecture.md): design, status, specs, and references.
|
|
89
105
|
- [Contributing](CONTRIBUTING.md): development setup, tests, and pull requests.
|
|
106
|
+
- [Changelog](CHANGELOG.md): release history and migration instructions.
|
|
90
107
|
- [Releasing](docs/releasing.md): package checks and tag-driven TestPyPI/PyPI publishing.
|
|
91
108
|
|
|
92
109
|
## License
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
[](https://pypi.org/project/flash-context/)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
A lightweight, model-agnostic repository explorer that combines deterministic
|
|
9
|
+
lookup with delegated LLM search to deliver compact, validated source evidence.
|
|
10
|
+
Available as a Python CLI and Model Context Protocol (MCP) server.
|
|
10
11
|
|
|
11
12
|
Delegate recursive, repetitive file search and reading to a utility model
|
|
12
13
|
behind an OpenAI-compatible endpoint. The calling agent receives validated
|
|
@@ -16,10 +17,17 @@ Codebases and repositories are the current focus; the same tools explore
|
|
|
16
17
|
documentation, configuration, and other local text files. CLI and MCP share one
|
|
17
18
|
exploration core with read-only tools, path safety, and resource limits.
|
|
18
19
|
Scans and regex execution are bounded; partial results report truncation.
|
|
20
|
+
Citation validation checks safe paths and existing line coverage, not semantic
|
|
21
|
+
relevance or minimal ranges. Citation precision depends on the explorer model.
|
|
22
|
+
Implementation queries continue past documentation-only citations within the turn budget; documentation-only
|
|
23
|
+
fallbacks carry incomplete-result metadata and warnings. Build outputs such as
|
|
24
|
+
`dist/` remain excluded; inspect a source checkout for implementation evidence.
|
|
19
25
|
Optional metadata-only trajectory logs must stay outside the inspected repository.
|
|
20
26
|
|
|
21
27
|
Inspired by [FastContext](https://arxiv.org/abs/2606.14066), a now-withdrawn paper
|
|
22
|
-
on
|
|
28
|
+
on separating repository exploration from task solving, using specialized models
|
|
29
|
+
to return focused file-line evidence while reducing the coding agent's context
|
|
30
|
+
and token overhead.
|
|
23
31
|
|
|
24
32
|
## Quickstart
|
|
25
33
|
|
|
@@ -50,6 +58,14 @@ lookups can resolve locally without an endpoint. See the
|
|
|
50
58
|
[CLI contract](specs/001-flash-context-explorer/contracts/cli.md) for options,
|
|
51
59
|
output schemas, and exit codes.
|
|
52
60
|
|
|
61
|
+
For example, `--query 'httpx/_client.py'` returns a bounded file preview, while
|
|
62
|
+
`--query 'BaseTransport'` returns its definition if unique within a complete
|
|
63
|
+
Python source/stub scan. Local successes report `turns_used: 0` in JSON.
|
|
64
|
+
|
|
65
|
+
Warnings go to stderr in both output formats. An empty result after scan-budget
|
|
66
|
+
exhaustion exits **6** while preserving stdout; a complete empty search exits
|
|
67
|
+
**0**. For large repositories, see [scan-budget tuning](docs/configuration.md#large-repositories).
|
|
68
|
+
|
|
53
69
|
## MCP
|
|
54
70
|
|
|
55
71
|
```bash
|
|
@@ -64,6 +80,7 @@ See [MCP integration](docs/mcp.md) for editor configuration and source launches.
|
|
|
64
80
|
- [Configuration](docs/configuration.md): endpoint settings, precedence, and limits.
|
|
65
81
|
- [Architecture and scope](docs/architecture.md): design, status, specs, and references.
|
|
66
82
|
- [Contributing](CONTRIBUTING.md): development setup, tests, and pull requests.
|
|
83
|
+
- [Changelog](CHANGELOG.md): release history and migration instructions.
|
|
67
84
|
- [Releasing](docs/releasing.md): package checks and tag-driven TestPyPI/PyPI publishing.
|
|
68
85
|
|
|
69
86
|
## License
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "flash-context"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "Read-only context exploration and retrieval for AI agents, starting with codebases and repositories."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -52,6 +52,7 @@ include = [
|
|
|
52
52
|
"/src/flash_context",
|
|
53
53
|
"/pyproject.toml",
|
|
54
54
|
"/README.md",
|
|
55
|
+
"/CHANGELOG.md",
|
|
55
56
|
"/LICENSE",
|
|
56
57
|
"/config.yaml.example",
|
|
57
58
|
"/.env.example",
|
|
@@ -74,5 +75,5 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
|
74
75
|
[tool.mypy]
|
|
75
76
|
python_version = "3.13"
|
|
76
77
|
strict = true
|
|
77
|
-
files = ["src/flash_context", "tests/unit", "tests/contract"]
|
|
78
|
+
files = ["src/flash_context", "tests/unit", "tests/contract", "scripts"]
|
|
78
79
|
exclude = ["tests/fixtures"]
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
5
|
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
-
from dataclasses import dataclass, field
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any, Literal, cast
|
|
9
9
|
|
|
@@ -66,7 +66,8 @@ TOKEN_RE = re.compile(r"\b(?P<token>[A-Za-z_][A-Za-z0-9_]*)\b")
|
|
|
66
66
|
class EvidenceState:
|
|
67
67
|
reads: list[ToolObservation] = field(default_factory=list)
|
|
68
68
|
citations: list[Citation] = field(default_factory=list)
|
|
69
|
-
|
|
69
|
+
observations: dict[str, ToolObservation] = field(default_factory=dict)
|
|
70
|
+
prefer_source: bool = False
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
def explore(
|
|
@@ -99,10 +100,11 @@ def explore(
|
|
|
99
100
|
)
|
|
100
101
|
messages = _initial_messages(request, file_sample, settings)
|
|
101
102
|
tools = tool_schemas()
|
|
102
|
-
|
|
103
|
-
warnings
|
|
103
|
+
# A capped file sample is expected; actual scan failures must reach callers.
|
|
104
|
+
warnings = list(file_sample.warnings)
|
|
105
|
+
truncated = bool(warnings)
|
|
104
106
|
last_content = ""
|
|
105
|
-
evidence = EvidenceState()
|
|
107
|
+
evidence = EvidenceState(prefer_source=_implementation_query(request.query))
|
|
106
108
|
|
|
107
109
|
try:
|
|
108
110
|
for turn in range(1, effective_max_turns + 1):
|
|
@@ -144,6 +146,7 @@ def explore(
|
|
|
144
146
|
recorder.finish(result=result)
|
|
145
147
|
return result
|
|
146
148
|
recorder.record_model_turn(turn, message)
|
|
149
|
+
needs_source = False
|
|
147
150
|
content_text = str(message.get("content") or "").strip()
|
|
148
151
|
if content_text:
|
|
149
152
|
last_content = content_text
|
|
@@ -156,6 +159,12 @@ def explore(
|
|
|
156
159
|
)
|
|
157
160
|
if citations:
|
|
158
161
|
evidence.citations = citations
|
|
162
|
+
needs_source = evidence.prefer_source and _documentation_only(
|
|
163
|
+
citations
|
|
164
|
+
)
|
|
165
|
+
if needs_source:
|
|
166
|
+
last_content = ""
|
|
167
|
+
if citations and not needs_source:
|
|
159
168
|
if _message_has_tool_calls(message):
|
|
160
169
|
_append_warning_once(
|
|
161
170
|
warnings,
|
|
@@ -177,6 +186,11 @@ def explore(
|
|
|
177
186
|
tool_calls = _extract_tool_calls(message)
|
|
178
187
|
if not tool_calls:
|
|
179
188
|
citations = _best_current_citations(evidence)
|
|
189
|
+
if evidence.prefer_source and _documentation_only(citations):
|
|
190
|
+
last_content = ""
|
|
191
|
+
messages.append({"role": "assistant", "content": content_text})
|
|
192
|
+
messages.append(_source_feedback())
|
|
193
|
+
continue
|
|
180
194
|
if request.citation and content_text != NO_CITATIONS_FOUND:
|
|
181
195
|
_append_warning_once(
|
|
182
196
|
warnings,
|
|
@@ -186,7 +200,7 @@ def explore(
|
|
|
186
200
|
request=request,
|
|
187
201
|
repo_root=repo_root,
|
|
188
202
|
settings=settings,
|
|
189
|
-
answer=content_text,
|
|
203
|
+
answer="" if needs_source else content_text,
|
|
190
204
|
citations=citations,
|
|
191
205
|
turns_used=turn,
|
|
192
206
|
truncated=truncated,
|
|
@@ -218,8 +232,12 @@ def explore(
|
|
|
218
232
|
recorder.finish(result=result)
|
|
219
233
|
return result
|
|
220
234
|
|
|
221
|
-
|
|
222
|
-
|
|
235
|
+
fresh_calls: dict[str, ToolCall] = {}
|
|
236
|
+
for tool_call in tool_calls:
|
|
237
|
+
fingerprint = _tool_call_fingerprint(tool_call)
|
|
238
|
+
if fingerprint not in evidence.observations:
|
|
239
|
+
fresh_calls.setdefault(fingerprint, tool_call)
|
|
240
|
+
if not fresh_calls:
|
|
223
241
|
_append_warning_once(
|
|
224
242
|
warnings,
|
|
225
243
|
"repeated tool call stopped exploration",
|
|
@@ -237,17 +255,18 @@ def explore(
|
|
|
237
255
|
)
|
|
238
256
|
recorder.finish(result=result)
|
|
239
257
|
return result
|
|
240
|
-
for tool_call in tool_calls:
|
|
241
|
-
evidence.seen_tool_calls.add(_tool_call_fingerprint(tool_call))
|
|
242
|
-
|
|
243
258
|
messages.append(_assistant_tool_message(message))
|
|
244
259
|
observations = _execute_tool_calls_parallel(
|
|
245
|
-
|
|
260
|
+
list(fresh_calls.values()),
|
|
246
261
|
repo_root=repo_root,
|
|
247
262
|
settings=settings,
|
|
248
263
|
)
|
|
249
|
-
|
|
250
|
-
|
|
264
|
+
evidence.observations.update(zip(fresh_calls, observations, strict=True))
|
|
265
|
+
for tool_call in tool_calls:
|
|
266
|
+
observation = replace(
|
|
267
|
+
evidence.observations[_tool_call_fingerprint(tool_call)],
|
|
268
|
+
tool_call_id=tool_call.id,
|
|
269
|
+
)
|
|
251
270
|
payload = _model_observation_payload(
|
|
252
271
|
observation,
|
|
253
272
|
max_chars=settings.max_observation_chars,
|
|
@@ -261,7 +280,9 @@ def explore(
|
|
|
261
280
|
and observation.path
|
|
262
281
|
and observation.line_range
|
|
263
282
|
):
|
|
264
|
-
|
|
283
|
+
read = replace(observation, tool_call_id=None)
|
|
284
|
+
if read not in evidence.reads:
|
|
285
|
+
evidence.reads.append(read)
|
|
265
286
|
recorder.record_observation(turn, tool_call.name, observation)
|
|
266
287
|
messages.append(
|
|
267
288
|
{
|
|
@@ -274,6 +295,8 @@ def explore(
|
|
|
274
295
|
),
|
|
275
296
|
}
|
|
276
297
|
)
|
|
298
|
+
if needs_source:
|
|
299
|
+
messages.append(_source_feedback())
|
|
277
300
|
if last_content:
|
|
278
301
|
citations = _validated_citations_from_content(
|
|
279
302
|
last_content,
|
|
@@ -417,6 +440,13 @@ def _finalize_result(
|
|
|
417
440
|
warnings: list[str],
|
|
418
441
|
) -> ExploreResult:
|
|
419
442
|
citations = _merge_adjacent_citations(citations)[:MAX_FINAL_CITATIONS]
|
|
443
|
+
if _implementation_query(request.query) and _documentation_only(citations):
|
|
444
|
+
truncated = True
|
|
445
|
+
answer = _citation_text(citations)
|
|
446
|
+
_append_warning_once(
|
|
447
|
+
warnings,
|
|
448
|
+
"implementation source not verified; returning documentation evidence",
|
|
449
|
+
)
|
|
420
450
|
if request.citation or (not answer and citations):
|
|
421
451
|
answer = _citation_text(citations)
|
|
422
452
|
raw_locations = _extract_raw_locations(
|
|
@@ -436,6 +466,7 @@ def _finalize_result(
|
|
|
436
466
|
truncated
|
|
437
467
|
or any(x.truncated for x in raw_locations)
|
|
438
468
|
or "citation verification limit reached" in warnings
|
|
469
|
+
or "read scan budget exhausted" in warnings
|
|
439
470
|
),
|
|
440
471
|
warnings=warnings,
|
|
441
472
|
)
|
|
@@ -448,33 +479,30 @@ def _citation_text(citations: list[Citation]) -> str:
|
|
|
448
479
|
|
|
449
480
|
|
|
450
481
|
def _merge_adjacent_citations(citations: list[Citation]) -> list[Citation]:
|
|
451
|
-
grouped: dict[str, list[
|
|
482
|
+
grouped: dict[str, list[Citation]] = {}
|
|
452
483
|
path_order: list[str] = []
|
|
453
484
|
non_mergeable: list[Citation] = []
|
|
454
|
-
for
|
|
485
|
+
for citation in citations:
|
|
455
486
|
if citation.start_line is None or citation.end_line is None:
|
|
456
487
|
non_mergeable.append(citation)
|
|
457
488
|
continue
|
|
458
489
|
if citation.path not in grouped:
|
|
459
490
|
grouped[citation.path] = []
|
|
460
491
|
path_order.append(citation.path)
|
|
461
|
-
grouped[citation.path].append(
|
|
492
|
+
grouped[citation.path].append(citation)
|
|
462
493
|
|
|
463
494
|
merged: list[Citation] = []
|
|
464
495
|
for path in path_order:
|
|
465
496
|
ranges = sorted(
|
|
466
497
|
grouped[path],
|
|
467
498
|
key=lambda item: (
|
|
468
|
-
item
|
|
469
|
-
item
|
|
499
|
+
item.start_line or 0,
|
|
500
|
+
item.end_line or 0,
|
|
470
501
|
),
|
|
471
502
|
)
|
|
472
503
|
current_start: int | None = None
|
|
473
504
|
current_end: int | None = None
|
|
474
|
-
|
|
475
|
-
current_reason_index: int | None = None
|
|
476
|
-
|
|
477
|
-
for index, citation in ranges:
|
|
505
|
+
for citation in ranges:
|
|
478
506
|
start = citation.start_line
|
|
479
507
|
end = citation.end_line
|
|
480
508
|
if start is None or end is None:
|
|
@@ -482,27 +510,18 @@ def _merge_adjacent_citations(citations: list[Citation]) -> list[Citation]:
|
|
|
482
510
|
if current_start is None or current_end is None:
|
|
483
511
|
current_start = start
|
|
484
512
|
current_end = end
|
|
485
|
-
current_reason = citation.reason
|
|
486
|
-
current_reason_index = index if citation.reason else None
|
|
487
513
|
continue
|
|
488
514
|
|
|
489
515
|
if start <= current_end + 1:
|
|
490
516
|
current_end = max(current_end, end)
|
|
491
|
-
if citation.reason and (
|
|
492
|
-
current_reason_index is None or index < current_reason_index
|
|
493
|
-
):
|
|
494
|
-
current_reason = citation.reason
|
|
495
|
-
current_reason_index = index
|
|
496
517
|
continue
|
|
497
518
|
|
|
498
|
-
merged.append(Citation(path, current_start, current_end
|
|
519
|
+
merged.append(Citation(path, current_start, current_end))
|
|
499
520
|
current_start = start
|
|
500
521
|
current_end = end
|
|
501
|
-
current_reason = citation.reason
|
|
502
|
-
current_reason_index = index if citation.reason else None
|
|
503
522
|
|
|
504
523
|
if current_start is not None and current_end is not None:
|
|
505
|
-
merged.append(Citation(path, current_start, current_end
|
|
524
|
+
merged.append(Citation(path, current_start, current_end))
|
|
506
525
|
|
|
507
526
|
merged.extend(non_mergeable)
|
|
508
527
|
return merged
|
|
@@ -562,6 +581,33 @@ def _try_exact_fast_path(
|
|
|
562
581
|
repo_root: Path,
|
|
563
582
|
settings: Settings,
|
|
564
583
|
) -> ExploreResult | None:
|
|
584
|
+
bare_path = request.query.strip()
|
|
585
|
+
if len(bare_path) >= 2 and bare_path[0] in "`'\"" and bare_path[-1] == bare_path[0]:
|
|
586
|
+
bare_path = bare_path[1:-1]
|
|
587
|
+
if re.fullmatch(r"(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+", bare_path):
|
|
588
|
+
try:
|
|
589
|
+
observation = read_file(
|
|
590
|
+
repo_root=repo_root,
|
|
591
|
+
path=bare_path,
|
|
592
|
+
end_line=settings.max_read_lines,
|
|
593
|
+
max_bytes=settings.max_read_bytes,
|
|
594
|
+
preview=True,
|
|
595
|
+
ignore=settings.ignore,
|
|
596
|
+
limits=settings.limits,
|
|
597
|
+
)
|
|
598
|
+
except ExplorerError:
|
|
599
|
+
pass # Missing, empty, or unsafe paths retain normal model routing.
|
|
600
|
+
else:
|
|
601
|
+
return _finalize_result(
|
|
602
|
+
request=request,
|
|
603
|
+
repo_root=repo_root,
|
|
604
|
+
settings=settings,
|
|
605
|
+
answer="",
|
|
606
|
+
citations=_citations_from_reads([observation]),
|
|
607
|
+
turns_used=0,
|
|
608
|
+
truncated=observation.truncated,
|
|
609
|
+
warnings=list(observation.warnings),
|
|
610
|
+
)
|
|
565
611
|
if not _is_exact_lookup(request.query):
|
|
566
612
|
return None
|
|
567
613
|
evidence = EvidenceState()
|
|
@@ -748,7 +794,6 @@ def _exact_matches_in_file(
|
|
|
748
794
|
grep_result,
|
|
749
795
|
target=target,
|
|
750
796
|
allow_assignment=allow_assignment,
|
|
751
|
-
definitions_only=False,
|
|
752
797
|
)
|
|
753
798
|
return citations if 0 < len(citations) <= MAX_FINAL_CITATIONS else []
|
|
754
799
|
|
|
@@ -759,32 +804,28 @@ def _unique_definition_citation(
|
|
|
759
804
|
settings: Settings,
|
|
760
805
|
symbol: str,
|
|
761
806
|
) -> Citation | None:
|
|
762
|
-
|
|
807
|
+
definition_pattern = rf"^\s*(?:async\s+def|def|class)\s+{re.escape(symbol)}\b"
|
|
808
|
+
grep_result = repo_grep(
|
|
763
809
|
repo_root=repo_root,
|
|
764
|
-
|
|
765
|
-
target=symbol,
|
|
810
|
+
pattern=definition_pattern,
|
|
766
811
|
glob="**/*.py",
|
|
812
|
+
max_results=2,
|
|
813
|
+
ignore=settings.ignore,
|
|
814
|
+
limits=settings.limits,
|
|
767
815
|
)
|
|
768
|
-
pyi_result =
|
|
816
|
+
pyi_result = repo_grep(
|
|
769
817
|
repo_root=repo_root,
|
|
770
|
-
|
|
771
|
-
target=symbol,
|
|
818
|
+
pattern=definition_pattern,
|
|
772
819
|
glob="**/*.pyi",
|
|
820
|
+
max_results=2,
|
|
821
|
+
ignore=settings.ignore,
|
|
822
|
+
limits=settings.limits,
|
|
773
823
|
)
|
|
774
|
-
citations =
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
)
|
|
780
|
-
citations.extend(
|
|
781
|
-
_citations_for_target_hits(
|
|
782
|
-
pyi_result,
|
|
783
|
-
target=symbol,
|
|
784
|
-
allow_assignment=False,
|
|
785
|
-
definitions_only=True,
|
|
786
|
-
)
|
|
787
|
-
)
|
|
824
|
+
citations = [
|
|
825
|
+
Citation(hit.path, hit.line, hit.line)
|
|
826
|
+
for result in (grep_result, pyi_result)
|
|
827
|
+
for hit in result.hits
|
|
828
|
+
]
|
|
788
829
|
if grep_result.truncated or pyi_result.truncated or len(citations) > 1:
|
|
789
830
|
return None
|
|
790
831
|
return citations[0] if len(citations) == 1 else None
|
|
@@ -812,7 +853,6 @@ def _citations_for_target_hits(
|
|
|
812
853
|
*,
|
|
813
854
|
target: str,
|
|
814
855
|
allow_assignment: bool,
|
|
815
|
-
definitions_only: bool,
|
|
816
856
|
) -> list[Citation]:
|
|
817
857
|
citations: list[Citation] = []
|
|
818
858
|
for hit in observation.hits:
|
|
@@ -820,7 +860,6 @@ def _citations_for_target_hits(
|
|
|
820
860
|
hit.text,
|
|
821
861
|
target=target,
|
|
822
862
|
allow_assignment=allow_assignment,
|
|
823
|
-
definitions_only=definitions_only,
|
|
824
863
|
):
|
|
825
864
|
citations.append(Citation(hit.path, hit.line, hit.line))
|
|
826
865
|
return citations
|
|
@@ -831,14 +870,11 @@ def _line_matches_target(
|
|
|
831
870
|
*,
|
|
832
871
|
target: str,
|
|
833
872
|
allow_assignment: bool,
|
|
834
|
-
definitions_only: bool,
|
|
835
873
|
) -> bool:
|
|
836
874
|
escaped = re.escape(target)
|
|
837
875
|
definition_pattern = rf"^\s*(?:async\s+def|def|class)\s+{escaped}\b"
|
|
838
876
|
if re.search(definition_pattern, line):
|
|
839
877
|
return True
|
|
840
|
-
if definitions_only:
|
|
841
|
-
return False
|
|
842
878
|
if allow_assignment and re.search(rf"^\s*{escaped}\s*(?::|=)", line):
|
|
843
879
|
return True
|
|
844
880
|
return bool(re.search(rf"\b{escaped}\b", line))
|
|
@@ -862,6 +898,7 @@ def _validated_citations_from_content(
|
|
|
862
898
|
repo_root=repo_root,
|
|
863
899
|
settings=settings,
|
|
864
900
|
evidence=evidence,
|
|
901
|
+
warnings=warnings,
|
|
865
902
|
)
|
|
866
903
|
if not validated:
|
|
867
904
|
_append_warning_once(warnings, "invalid citation rejected")
|
|
@@ -874,6 +911,7 @@ def _validate_citations(
|
|
|
874
911
|
repo_root: Path,
|
|
875
912
|
settings: Settings,
|
|
876
913
|
evidence: EvidenceState,
|
|
914
|
+
warnings: list[str],
|
|
877
915
|
) -> list[Citation]:
|
|
878
916
|
validated: list[Citation] = []
|
|
879
917
|
for citation in citations[:16]:
|
|
@@ -888,6 +926,7 @@ def _validate_citations(
|
|
|
888
926
|
repo_root=repo_root,
|
|
889
927
|
settings=settings,
|
|
890
928
|
evidence=evidence,
|
|
929
|
+
warnings=warnings,
|
|
891
930
|
)
|
|
892
931
|
if verified is not None:
|
|
893
932
|
validated.append(verified)
|
|
@@ -909,7 +948,6 @@ def _normalize_citation(citation: Citation) -> Citation | None:
|
|
|
909
948
|
path=citation.path,
|
|
910
949
|
start_line=citation.start_line,
|
|
911
950
|
end_line=citation.end_line,
|
|
912
|
-
reason=citation.reason,
|
|
913
951
|
)
|
|
914
952
|
|
|
915
953
|
|
|
@@ -961,6 +999,7 @@ def _verify_citation_by_read(
|
|
|
961
999
|
repo_root: Path,
|
|
962
1000
|
settings: Settings,
|
|
963
1001
|
evidence: EvidenceState,
|
|
1002
|
+
warnings: list[str],
|
|
964
1003
|
) -> Citation | None:
|
|
965
1004
|
if citation.start_line is None or citation.end_line is None:
|
|
966
1005
|
return None
|
|
@@ -974,13 +1013,16 @@ def _verify_citation_by_read(
|
|
|
974
1013
|
ignore=settings.ignore,
|
|
975
1014
|
limits=settings.limits,
|
|
976
1015
|
)
|
|
977
|
-
except ExplorerError:
|
|
1016
|
+
except ExplorerError as exc:
|
|
1017
|
+
if exc.code == "TOOL_LIMIT_EXCEEDED":
|
|
1018
|
+
_append_warning_once(warnings, "read scan budget exhausted")
|
|
978
1019
|
return None
|
|
1020
|
+
for warning in observation.warnings:
|
|
1021
|
+
_append_warning_once(warnings, warning)
|
|
979
1022
|
verified = Citation(
|
|
980
1023
|
path=observation.path or citation.path,
|
|
981
1024
|
start_line=citation.start_line,
|
|
982
1025
|
end_line=citation.end_line,
|
|
983
|
-
reason=citation.reason,
|
|
984
1026
|
)
|
|
985
1027
|
if verified.path != citation.path:
|
|
986
1028
|
return None
|
|
@@ -1003,6 +1045,19 @@ def _dedupe_citations(citations: list[Citation]) -> list[Citation]:
|
|
|
1003
1045
|
|
|
1004
1046
|
|
|
1005
1047
|
def _best_current_citations(evidence: EvidenceState) -> list[Citation]:
|
|
1048
|
+
if evidence.prefer_source:
|
|
1049
|
+
source_reads = [
|
|
1050
|
+
read for read in evidence.reads if not _documentation_path(read.path or "")
|
|
1051
|
+
]
|
|
1052
|
+
source_citations = [
|
|
1053
|
+
citation
|
|
1054
|
+
for citation in evidence.citations
|
|
1055
|
+
if not _documentation_path(citation.path)
|
|
1056
|
+
]
|
|
1057
|
+
if source_citations or source_reads:
|
|
1058
|
+
return source_citations[:MAX_FINAL_CITATIONS] or _citations_from_reads(
|
|
1059
|
+
source_reads
|
|
1060
|
+
)
|
|
1006
1061
|
if evidence.citations:
|
|
1007
1062
|
return evidence.citations[:MAX_FINAL_CITATIONS]
|
|
1008
1063
|
return _citations_from_reads(evidence.reads)
|
|
@@ -1107,17 +1162,44 @@ def _tool_call_fingerprint(tool_call: ToolCall) -> str:
|
|
|
1107
1162
|
)
|
|
1108
1163
|
|
|
1109
1164
|
|
|
1110
|
-
def
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
)
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1165
|
+
def _implementation_query(query: str) -> bool:
|
|
1166
|
+
if _is_exact_lookup(query) or re.search(
|
|
1167
|
+
r"\b(?:docs?|documentation|markdown|readme)\b", query, re.IGNORECASE
|
|
1168
|
+
):
|
|
1169
|
+
return False
|
|
1170
|
+
return bool(
|
|
1171
|
+
re.search(
|
|
1172
|
+
r"\b(?:implement\w*|handl\w*|code|functions?|methods?|classes|class)\b",
|
|
1173
|
+
query,
|
|
1174
|
+
re.IGNORECASE,
|
|
1175
|
+
)
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _documentation_path(path: str) -> bool:
|
|
1180
|
+
name = Path(path).name.lower()
|
|
1181
|
+
return Path(name).suffix in {".md", ".mdx", ".rst", ".txt", ".adoc"} or name in {
|
|
1182
|
+
"readme",
|
|
1183
|
+
"changelog",
|
|
1184
|
+
"contributing",
|
|
1185
|
+
"license",
|
|
1186
|
+
"authors",
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _documentation_only(citations: list[Citation]) -> bool:
|
|
1191
|
+
return bool(citations) and all(_documentation_path(c.path) for c in citations)
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _source_feedback() -> dict[str, str]:
|
|
1195
|
+
return {
|
|
1196
|
+
"role": "user",
|
|
1197
|
+
"content": (
|
|
1198
|
+
"These citations only establish documentation evidence. Continue searching "
|
|
1199
|
+
"for implementation source using fresh, focused read-only tool calls. "
|
|
1200
|
+
"Do not repeat previous calls or bypass denied paths."
|
|
1201
|
+
),
|
|
1202
|
+
}
|
|
1121
1203
|
|
|
1122
1204
|
|
|
1123
1205
|
def _message_has_tool_calls(message: dict[str, Any]) -> bool:
|
|
@@ -1232,6 +1314,11 @@ def _initial_messages(
|
|
|
1232
1314
|
"tool calls in the same turn. "
|
|
1233
1315
|
"For exact symbol, function, or class names, use repo_grep "
|
|
1234
1316
|
"before reading broad file ranges. "
|
|
1317
|
+
"For implementation questions, prefer implementation source over "
|
|
1318
|
+
"documentation or Markdown; use documentation when requested or "
|
|
1319
|
+
"needed to support the answer. Cite the smallest line ranges "
|
|
1320
|
+
"that support the answer, rather than whole files or classes "
|
|
1321
|
+
"when a smaller function or region suffices. "
|
|
1235
1322
|
f"Use read_file line ranges of at most {settings.max_read_lines} "
|
|
1236
1323
|
"lines unless no narrower range is known. "
|
|
1237
1324
|
f"{output_contract}"
|
|
@@ -1464,7 +1551,11 @@ def _execute_tool_call(
|
|
|
1464
1551
|
limits=settings.limits,
|
|
1465
1552
|
)
|
|
1466
1553
|
except ExplorerError as exc:
|
|
1467
|
-
|
|
1554
|
+
observation = ToolObservation.from_error(exc, tool_call_id=tool_call.id)
|
|
1555
|
+
if exc.code == "TOOL_LIMIT_EXCEEDED":
|
|
1556
|
+
observation.truncated = True
|
|
1557
|
+
observation.warnings.append("read scan budget exhausted")
|
|
1558
|
+
return observation
|
|
1468
1559
|
raise AssertionError(f"unhandled tool: {tool_call.name}")
|
|
1469
1560
|
|
|
1470
1561
|
|
|
@@ -11,6 +11,15 @@ from flash_context.config import SettingsOverrides, load_settings
|
|
|
11
11
|
from flash_context.mcp_server import run_mcp
|
|
12
12
|
from flash_context.types import ExploreRequest, ExplorerError
|
|
13
13
|
|
|
14
|
+
_SCAN_LIMIT_WARNINGS = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"glob scan budget exhausted",
|
|
17
|
+
"grep scan budget exhausted",
|
|
18
|
+
"read scan budget exhausted",
|
|
19
|
+
"grep match timed out",
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
14
23
|
|
|
15
24
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
16
25
|
parser = _build_parser()
|
|
@@ -60,6 +69,12 @@ def _run_explore(args: argparse.Namespace) -> int:
|
|
|
60
69
|
print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
|
|
61
70
|
else:
|
|
62
71
|
print(format_text_result(result, citation=args.citation))
|
|
72
|
+
for warning in result.warnings:
|
|
73
|
+
print(f"WARNING: {warning}", file=sys.stderr)
|
|
74
|
+
if result.truncated and not result.warnings:
|
|
75
|
+
print("WARNING: Result truncated", file=sys.stderr)
|
|
76
|
+
if not result.citations and _SCAN_LIMIT_WARNINGS.intersection(result.warnings):
|
|
77
|
+
return 6
|
|
63
78
|
return 0
|
|
64
79
|
|
|
65
80
|
|
|
@@ -50,16 +50,19 @@ class TrajectoryRecorder:
|
|
|
50
50
|
self, turn: int, tool_name: str, observation: ToolObservation
|
|
51
51
|
) -> None:
|
|
52
52
|
if self._traj_dir is not None:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
metadata: dict[str, object] = {
|
|
54
|
+
"turn": turn,
|
|
55
|
+
"tool": tool_name,
|
|
56
|
+
"ok": observation.ok,
|
|
57
|
+
"truncated": observation.truncated,
|
|
58
|
+
}
|
|
59
|
+
if tool_name == "read_file":
|
|
60
|
+
metadata["content_length"] = len(observation.content or "")
|
|
61
|
+
elif tool_name == "repo_grep":
|
|
62
|
+
metadata["hit_count"] = len(observation.hits)
|
|
63
|
+
elif tool_name == "repo_glob":
|
|
64
|
+
metadata["match_count"] = len(observation.matches)
|
|
65
|
+
self._turns.append(metadata)
|
|
63
66
|
|
|
64
67
|
def finish(
|
|
65
68
|
self, *, result: ExploreResult | None = None, error: ExplorerError | None = None
|
|
@@ -6,6 +6,7 @@ from importlib import import_module
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
+
from flash_context import __version__
|
|
9
10
|
from flash_context.agent import explore
|
|
10
11
|
from flash_context.config import Settings, SettingsOverrides, load_settings
|
|
11
12
|
from flash_context.types import ExploreRequest, ExplorerError, ExploreResult
|
|
@@ -52,6 +53,8 @@ def create_server() -> Any:
|
|
|
52
53
|
) from exc
|
|
53
54
|
fast_mcp = fastmcp_module.FastMCP
|
|
54
55
|
server = fast_mcp("flash-context")
|
|
56
|
+
# FastMCP exposes no version argument; the low-level default is the SDK version.
|
|
57
|
+
server._mcp_server.version = __version__
|
|
55
58
|
|
|
56
59
|
@server.tool() # type: ignore[untyped-decorator]
|
|
57
60
|
def explore_repository(
|
|
@@ -60,6 +63,13 @@ def create_server() -> Any:
|
|
|
60
63
|
max_turns: int | None = None,
|
|
61
64
|
citation: bool | None = None,
|
|
62
65
|
) -> dict[str, object]:
|
|
66
|
+
"""Find code or local text relevant to a focused repository question.
|
|
67
|
+
|
|
68
|
+
Explore read-only when implementation locations or supporting evidence
|
|
69
|
+
are unknown. Return validated file-line citations and bounded source
|
|
70
|
+
snippets, with warnings for incomplete exploration. repo_root defaults
|
|
71
|
+
to the server's working directory.
|
|
72
|
+
"""
|
|
63
73
|
return explore_repository_handler(
|
|
64
74
|
query=query,
|
|
65
75
|
repo_root=repo_root,
|
|
@@ -173,7 +173,31 @@ class PathSafety:
|
|
|
173
173
|
except OSError:
|
|
174
174
|
budget.warnings.append("search skipped an unreadable directory")
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
directory = self.repo_root
|
|
177
|
+
parts = Path(pattern).parts
|
|
178
|
+
try:
|
|
179
|
+
for index, part in enumerate(parts):
|
|
180
|
+
if any(char in part for char in "*?["):
|
|
181
|
+
break
|
|
182
|
+
candidate = directory / part
|
|
183
|
+
budget.consume(entries=1)
|
|
184
|
+
self.ensure_allowed(candidate)
|
|
185
|
+
if index == len(parts) - 1:
|
|
186
|
+
if candidate.exists() and not candidate.is_dir():
|
|
187
|
+
yield self.resolve_existing_file(candidate)
|
|
188
|
+
return
|
|
189
|
+
# Match the walker's policy: never traverse directory symlinks.
|
|
190
|
+
if candidate.is_symlink() or not candidate.is_dir():
|
|
191
|
+
return
|
|
192
|
+
directory = candidate
|
|
193
|
+
yield from walk(directory)
|
|
194
|
+
except ExplorerError as exc:
|
|
195
|
+
if exc.code == "TOOL_LIMIT_EXCEEDED":
|
|
196
|
+
raise
|
|
197
|
+
if exc.code not in {"PATH_DENIED", "PATH_OUTSIDE_ROOT"}:
|
|
198
|
+
budget.warnings.append("search skipped an unreadable entry")
|
|
199
|
+
except OSError:
|
|
200
|
+
budget.warnings.append("search skipped an unreadable directory")
|
|
177
201
|
|
|
178
202
|
|
|
179
203
|
def _matches_patterns(rel_path: str, patterns: tuple[str, ...]) -> bool:
|
|
@@ -49,14 +49,12 @@ class Citation:
|
|
|
49
49
|
path: str
|
|
50
50
|
start_line: int | None = None
|
|
51
51
|
end_line: int | None = None
|
|
52
|
-
reason: str | None = None
|
|
53
52
|
|
|
54
53
|
def to_dict(self) -> dict[str, object]:
|
|
55
54
|
return {
|
|
56
55
|
"path": self.path,
|
|
57
56
|
"start_line": self.start_line,
|
|
58
57
|
"end_line": self.end_line,
|
|
59
|
-
"reason": self.reason,
|
|
60
58
|
}
|
|
61
59
|
|
|
62
60
|
def label(self) -> str:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|