From 376f092d1bd918e69c65c479de7357cf53d02f73 Mon Sep 17 00:00:00 2001 From: Krishika <203898617+krishika08@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:32:51 +0530 Subject: [PATCH 1/2] Bump version to 1.2.0 and update release documentation - Set the version to 1.2.0 in pyproject.toml and reponeedle/__init__.py. - Add CHANGELOG.md for 1.2.0 (reviewed against the code): CLI JSON and anchors, MCP server, indexing security, indexing diagnostics, abstention disabled by default, optional extras, backward compatibility, migration and known limitations. No retrieval improvement is claimed. - Replace pre-release wording ("planned 1.2.0", "development branch only", "from source only") in README, KNOWN_LIMITATIONS, CLAIMS_AND_EVIDENCE and docs/. MCP installation points to `pip install "reponeedle[mcp]>=1.2.0"`, with 1.2.0 marked upcoming until it is published and a from-source fallback. Historical benchmark provenance is unchanged. - tests/test_documentation_consistency.py: replace the check that skipped itself once the packaged version reached 1.2 (a skip fails the strict Linux CI job) with checks that apply to any version, and fail if "planned " wording remains. The demo API and Hugging Face Space still bundle the 1.1.0 engine and are not changed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 149 ++++++++++++++++++ CLAIMS_AND_EVIDENCE.md | 4 +- KNOWN_LIMITATIONS.md | 18 +-- README.md | 41 ++--- docs/ANNOUNCEMENT.md | 6 +- docs/BENCHMARK_CONFIGURATION.md | 7 +- docs/MCP_INTEGRATION.md | 16 +- docs/ROADMAP.md | 9 +- pyproject.toml | 2 +- reponeedle/__init__.py | 2 +- .../PROPOSED_CHANGELOG_1.2.0.md | 2 + tests/test_documentation_consistency.py | 29 +++- 12 files changed, 230 insertions(+), 55 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..210792b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,149 @@ +# Changelog + +All notable changes to RepoNeedle are documented here. Versions follow [Semantic Versioning](https://semver.org/). + +## [1.2.0] — Unreleased (upcoming) + +> 1.2.0 has not yet been published to PyPI. Until it is, `pip install reponeedle` installs 1.1.0. + +### Highlights + +- **Native MCP server** (`reponeedle-mcp`, optional `mcp` extra) for MCP-capable coding agents over STDIO. +- **Machine-readable CLI output** (`--json`) and **anchor files** (`--anchors`). +- **Indexing security**: files reached through links that resolve outside the repository are no longer read. +- **Indexing diagnostics** and a configurable file-size limit. +- **Behaviour change**: the experimental selective-abstention heuristic is now **disabled by default**. + +This release does **not** change retrieval quality. No ranking algorithm, fusion weight, task routing or packing default was changed, and no retrieval improvement is claimed. See [Not changed](#not-changed-in-120). + +### Added + +#### MCP server (optional `mcp` extra) +- Install: `pip install "reponeedle[mcp]"`. Run: `reponeedle-mcp --repo /path/to/project` (or `python -m reponeedle.mcp`). +- Tools: + - `reponeedle_retrieve`: context retrieval within a token budget. Optional `enable_experimental_abstention`, default `false`. + - `reponeedle_change_impact`: files and tests related to modified anchor files. + - `reponeedle_inspect`: index metadata, indexing diagnostics and abstention status. +- The repository is indexed once at startup; restart the server to pick up changes. Path arguments are confined to the repository root. +- `serverInfo.version` reports RepoNeedle's version. +- STDIO transport only. Tested with the MCP Python SDK and a JSON-RPC STDIO session. It has **not** been validated in a live session of any specific coding client (Claude Code, Cursor, Cline, Windsurf); configuration examples follow each client's documented format. +- Without the extra, the core package is unaffected, and `reponeedle-mcp` prints installation instructions. + +#### CLI +- `--json`: emits a single JSON document on stdout (`schema_version` `1.2.0`). It contains selected files, scores, tiers, token counts, and `abstention` and `indexing` objects. Diagnostics go to stderr only. +- `--include-context`: includes the rendered prompt context in the JSON output. +- `--anchors PATH [PATH ...]`: seed (anchor) files for change-impact and ripple queries. Paths are validated and must stay inside the repository. Windows-style paths (`src\app.py`) are accepted on Linux and macOS. +- `--enable-experimental-abstention`: opts in to the experimental abstention heuristic. +- `--max-file-size-bytes BYTES`: per-file indexing limit (default 500,000). + +#### Python API +- `RepoNeedleEngine.retrieve(..., anchor_files=[...])` and `Query(anchor_files=[...])`. +- `RepoNeedleEngine.from_directory(..., max_file_size_bytes=500_000)`. A zero, negative or non-integer limit raises an error. +- `engine.indexing_diagnostics` (`IndexingDiagnostics`) reports: + - the number of indexed files; + - skipped oversized files (paths only); + - unreadable files; + - links resolving outside the repository; + - whether the `max_files` cap was reached. +- `RetrievalResult.abstention` (`AbstentionDiagnostics`): whether the heuristic was applied, with `experimental: true` and `calibrated: false`. +- `BudgetConfig.enable_experimental_abstention` and `retrieve(..., enable_experimental_abstention=...)`. +- New exports: `AbstentionDiagnostics`, `IndexingDiagnostics`, `DEFAULT_MAX_FILE_SIZE_BYTES`. + +### Changed + +- **Experimental abstention is off by default.** In 1.1.x every query could abstain on heuristic grounds. The heuristic is uncalibrated: it correctly abstained on 0 of 82 no-gold queries in the ARB v2 selective split. In 1.2.0, out-of-scope queries return ranked results unless you opt in (see [Migration](#migrating-from-11x)). + - `task_type="selective"` does not enable it. + - Empty repositories still return `abstained=True`. + - `confidence_score` is still reported. It remains an uncalibrated diagnostic, not a probability. +- **Skipped files are reported.** A `WARNING` is logged on the `reponeedle.engine` logger (stderr by default) when files are skipped, and details are recorded in `indexing_diagnostics`. Files are skipped when they: + - exceed the size limit; + - are unreadable; + - resolve outside the repository; + - fall beyond the `max_files` cap. + + The warning and diagnostics contain repository-relative paths and counts only, never file contents. + +### Security + +- **Indexing no longer reads files outside the repository through links.** `from_directory` resolves every file before opening it. Files whose canonical location is outside the repository root (symlinks, and junctions on Windows) are skipped without reading the target. Each skipped link is listed by its in-repository path in `indexing_diagnostics.symlink_outside_repository_paths` (reason `symlink_outside_repository`). + - In 1.1.0 such files were indexed, and their contents could be returned by retrieval, including through any tool built on it. + - Links resolving inside the repository are still indexed. +- CLI anchors use the same canonical containment check: an anchor that links outside the repository is rejected. + +### Fixed + +- Anchor and MCP path validation now check whole path components. Legitimate names beginning with `..` (for example `..cache/x.py`) are accepted; real parent-directory traversal is still rejected. +- CLI anchors written with backslashes (`src\app.py`) are accepted on Linux and macOS, consistent with `retrieve(anchor_files=...)`. The exact spelling is tried first. Traversal such as `..\outside.py` is still rejected. +- The MCP server reports RepoNeedle's version in `serverInfo.version`, instead of the MCP SDK's. + +### Optional extras + +| Extra | Installs | Purpose | +| :--- | :--- | :--- | +| `mcp` | `mcp>=1.28,<2` | MCP server (`reponeedle-mcp`) | +| `api` | `fastapi`, `httpx`, `uvicorn` | Dependencies for the repository's demonstration HTTP API (`api.py`). Present since 1.1.0. No module inside the `reponeedle` package imports them. | +| `dev` | `pytest`, `pytest-cov`, `pytest-asyncio` | Test tooling. `pytest-asyncio` is new in 1.2.0. | +| `hf` | `huggingface_hub`, `gradio` | Hugging Face release tooling (unchanged) | + +The core runtime dependencies are unchanged from 1.1.0: `numpy`, `scipy`, `networkx`, `pydantic`, `pathspec`, `rich`, and `tomli` on Python < 3.11. Python 3.10, 3.11 and 3.12 are supported. + +### Backward compatibility + +- All 1.1.0 public call signatures, `RetrievalResult` fields and CLI options keep working. New parameters are optional keywords, and new result fields are additions. +- The default ranker (`legacy`) and all ranking, fusion, routing and packing defaults are unchanged. On every ARB v2 query compared (82 selective and 53 positive), the pre-release development branch produced rankings bit-identical to 1.1.0. Later changes in 1.2.0 do not touch the ranking path; they were not re-measured. +- `change_impact` results for `AUTO` queries report the router-classified task, as in 1.1.0. This is now covered by regression tests. +- Behaviour differs from 1.1.0 in three places: + - abstention is off by default; + - links resolving outside the repository are no longer indexed; + - skipped files produce a logged warning. + +### Migrating from 1.1.x + +- **Restore the 1.1.x abstention behaviour:** + - Python, engine-wide: `RepoNeedleEngine.from_directory(path, budget_config=BudgetConfig(enable_experimental_abstention=True))` + - Python, per call: `engine.retrieve(..., enable_experimental_abstention=True)` + - CLI: `--enable-experimental-abstention` + - MCP: `"enable_experimental_abstention": true` +- **If you relied on indexing files through links that point outside the repository:** move or copy those files into the repository, or point `--repo` / `from_directory` at the directory that actually contains them. Check `engine.indexing_diagnostics.symlink_outside_repository_paths` to see what was skipped. +- **If large files are skipped:** raise the limit with `max_file_size_bytes` (Python) or `--max-file-size-bytes` (CLI and MCP). +- **If the new indexing warning is unwanted:** adjust the `reponeedle.engine` logger level with the standard `logging` configuration. + +### Development and CI + +- New Linux CI job (`mcp-security-linux`): installs `.[dev,mcp]` and runs the MCP, link-security and compatibility suites. Skipped tests fail the job (`REPONEEDLE_FAIL_ON_SKIP=1`). It also runs a live MCP STDIO start-up check. +- Release workflow, before publishing: + 1. A `validate` job checks that the tag, `pyproject.toml` and `reponeedle.__version__` agree. It then runs the full test suite and the strict MCP/security suites. + 2. `build` runs after `validate` and checks the built artifact versions. + 3. `publish` runs only after both succeed. It is the only job with publishing (OIDC) permission. + +### Documentation and evaluation + +- README restructured with a quickstart, a release-status table and a clear separation of benchmark kinds. +- Historical ARB v2 results remain as published, labelled with their engine provenance (commit `8e00425`, 2026-10-28). +- Current-engine official-harness results, a supplemental whole-file evaluation, and experimental studies are reported separately (`docs/BENCHMARK_CONFIGURATION.md`, `reports/experimental/`). Supplemental and experimental results are **not** official ARB scores. +- Corrected earlier inaccurate claims: + - synthetic "100% abstention accuracy"; + - "calibrated abstention"; + - "100% test coverage"; + - abstention threshold and score ranges; + - the `comment2context` root-cause analysis. + +### Not changed in 1.2.0 + +- **Documentation filtering** (`enable_doc_filter`) remains **disabled by default**. A pre-registered held-out validation did **not** validate it. +- **Lexical retrieval** is unchanged. The investigated lexical optimisations were **not validated**, and none was adopted. +- The Hugging Face Space and the demonstration API (`api.py`) still bundle the 1.1.0 engine. They are updated separately. + +### Known limitations + +- The abstention heuristic is experimental and uncalibrated. Out-of-scope queries are not detected by default. +- `comment2context` deep recall remains below BM25 on matched whole-file input (supplemental evaluation: Recall@20 −0.090, repository-clustered 95% CI [−0.178, −0.006]). +- Automatic task routing (`AUTO`) agrees with ARB task labels on 36% of queries. Benchmark results pass task labels explicitly. +- Not implemented: a persistent index cache, live filesystem updates, HTTP/SSE MCP transport, and AST block slicing of large files. +- The MCP server indexes once at startup, and `skipped_paths` is unbounded. + +See [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) for details. + +## [1.1.0] — 2026-11-03 + +Opt-in `change_impact` ranker (seed-relative change-impact retrieval). See [docs/v1.1_change_impact_release.md](docs/v1.1_change_impact_release.md). diff --git a/CLAIMS_AND_EVIDENCE.md b/CLAIMS_AND_EVIDENCE.md index 15c7ffe..279ece7 100644 --- a/CLAIMS_AND_EVIDENCE.md +++ b/CLAIMS_AND_EVIDENCE.md @@ -1,6 +1,6 @@ # CLAIMS AND EVIDENCE: SCIENTIFIC RECONCILIATION FOR REPONEEDLE -**Version**: Evidence originally compiled for the 0.1.0 Research Preview. Claims C11–C13 and corrections to C2, C4 and C6 were added on 2026-11-09 and cover released 1.1.0 and the unreleased development branch planned for 1.2.0. +**Version**: Evidence originally compiled for the 0.1.0 Research Preview. Claims C11–C13 and corrections to C2, C4 and C6 were added on 2026-11-09 and cover released 1.1.0 and the pre-release development branch that became 1.2.0 (upcoming; PyPI publication pending). **Benchmark Suite**: Official Agent Retrieval Bench (ARB v2, $N=427$ total samples, $N=345$ positive retrieval samples across 25 repositories and 271 unique repository snapshots) **Evaluator**: `agent_retrieval_bench` v0.2.1, Commit `9e4d68937f29` **Evaluation Date**: 2026-10-28 @@ -29,7 +29,7 @@ This document provides a line-by-line verification of all scientific, empirical, ### Re-check on the Current Engine (2026-11-09) -The ARB-derived evidence for C1–C8 and C10 was measured on 2026-10-28 with the engine at commit `8e00425` (tag `v0.1.0-baseline`). Engine changes made before the 1.1.0 release altered the rankings, so the ARB v2 positive split was re-measured with the current engine (1.1.0, identical to the development branch). Method and full tables: [docs/BENCHMARK_CONFIGURATION.md §7](docs/BENCHMARK_CONFIGURATION.md#7-verification-performed-2026-11-09). +The ARB-derived evidence for C1–C8 and C10 was measured on 2026-10-28 with the engine at commit `8e00425` (tag `v0.1.0-baseline`). Engine changes made before the 1.1.0 release altered the rankings, so the ARB v2 positive split was re-measured with the current engine (1.1.0, and the pre-release 1.2.0 development branch of 2026-11-09, which produced identical rankings). Method and full tables: [docs/BENCHMARK_CONFIGURATION.md §7](docs/BENCHMARK_CONFIGURATION.md#7-verification-performed-2026-11-09). | # | Status on the current engine | Current evidence | | :-: | :---: | :--- | diff --git a/KNOWN_LIMITATIONS.md b/KNOWN_LIMITATIONS.md index 4e30949..39385ad 100644 --- a/KNOWN_LIMITATIONS.md +++ b/KNOWN_LIMITATIONS.md @@ -1,6 +1,6 @@ # KNOWN LIMITATIONS AND ARCHITECTURAL BOUNDARIES: REPONEEDLE -**Applies to**: Released 1.1.0 (PyPI) and the current development branch (planned for 1.2.0, unreleased). Differences between the two are called out explicitly. +**Applies to**: Released 1.1.0 (PyPI) and 1.2.0 (upcoming; PyPI publication pending). Differences between the two are called out explicitly. **Originally written for**: 0.1.0 Research Preview (October 2026); last reconciled with the source code on 2026-11-09. **Primary Benchmark**: Agent Retrieval Bench v2 ($N=427$ samples, 25 repositories, 271 unique snapshots) @@ -40,8 +40,8 @@ Measured on the 82 ARB v2 no-gold queries (source: `reports/full_arb_validation/ ### Required Resolution Heuristic confidence scoring based on surface lexical overlap is scientifically inadequate for repository abstention. Future versions will require a dedicated, fine-tuned cross-encoder or neural classifier trained specifically to discriminate internal bugs from out-of-scope issues. Until then, RepoNeedle's selective abstention feature must be treated as purely experimental. -### Mitigation on the Development Branch (planned 1.2.0): Disabled by Default (Behavior Change) -In released 1.1.0 the heuristic runs on every query. On the development branch it is no longer applied unless explicitly requested. The algorithm, confidence formula and threshold semantics are unchanged; only the default changed. +### Mitigation in 1.2.0: Disabled by Default (Behavior Change) +In released 1.1.0 the heuristic runs on every query. From 1.2.0 it is no longer applied unless explicitly requested. The algorithm, confidence formula and threshold semantics are unchanged; only the default changed. | Surface | How to opt in | | :--- | :--- | @@ -63,7 +63,7 @@ Consequences users must understand: ## 3. Deep Recall Deficit Compared to RepoMap > [!NOTE] -> Figures in this section come from the 2026-10-28 engine (README Part A). On the current engine (1.1.0 / development branch, re-measured 2026-11-09) the gap is larger: Recall@20 is 0.3842 vs RepoMap 0.6306 (RepoMap +64.2%), and BCY@8k is 0.3443 vs 0.3788 (RepoMap +10.0%). MRR remains at parity (+0.0013, repository-clustered CI [−0.0597, +0.0517]). See [docs/BENCHMARK_CONFIGURATION.md §7.3](docs/BENCHMARK_CONFIGURATION.md#73-current-engine-re-measurement-arb-v2-positive-n345). +> Figures in this section come from the 2026-10-28 engine (README Part A). On the current engine (1.1.0 and the pre-release 1.2.0 development branch, identical rankings; re-measured 2026-11-09) the gap is larger: Recall@20 is 0.3842 vs RepoMap 0.6306 (RepoMap +64.2%), and BCY@8k is 0.3443 vs 0.3788 (RepoMap +10.0%). MRR remains at parity (+0.0013, repository-clustered CI [−0.0597, +0.0517]). See [docs/BENCHMARK_CONFIGURATION.md §7.3](docs/BENCHMARK_CONFIGURATION.md#73-current-engine-re-measurement-arb-v2-positive-n345). ### The Observed Defect While RepoNeedle matches or slightly exceeds RepoMap on top-rank precision (MRR: `0.2287` vs `0.2140`, a non-significant +6.9% difference, $p=0.4089$), **RepoMap substantially outperforms RepoNeedle at high recall depth**: @@ -124,11 +124,11 @@ In repositories containing large monolithic source files (>2,000 lines of code, `RepoNeedleEngine.from_directory` does not index every file in a repository: 1. **File-size limit**: files **strictly larger** than `max_file_size_bytes` (default `500_000` bytes) are excluded. A file exactly at the limit is indexed. The limit is configurable (`from_directory(..., max_file_size_bytes=...)`, CLI/MCP `--max-file-size-bytes`) and must be a positive integer. It is never raised automatically. 2. **Unreadable files**: files whose size or contents cannot be read are excluded (including broken links and link loops). -2b. **Links resolving outside the repository** (development branch): files whose canonical location (following symlinks, and junctions on Windows) is outside the repository root are excluded without reading the target and recorded by link path (`symlink_outside_repository_paths`). In released 1.1.0 such files were read and indexed. +2b. **Links resolving outside the repository** (1.2.0): files whose canonical location (following symlinks, and junctions on Windows) is outside the repository root are excluded without reading the target and recorded by link path (`symlink_outside_repository_paths`). In released 1.1.0 such files were read and indexed. 3. **`max_files` cap**: indexing stops after `max_files` (default `3000`) files. 4. **Ignore rules and extensions**: `.gitignore` entries, built-in ignore patterns (`node_modules`, `dist`, `build`, …) and unsupported extensions are excluded by design and are **not** reported as skipped. -### Visibility (development branch, planned 1.2.0) +### Visibility (1.2.0) Exclusions in categories 1–3 are recorded in `engine.indexing_diagnostics` (`skipped_paths`, `unreadable_paths`, `max_files_reached`, plus counts), exposed in CLI `--json` output under `indexing`, and returned by the MCP `reponeedle_inspect` tool. A summary `WARNING` is emitted on the `reponeedle.engine` logger (stderr by default; never on JSON stdout). Diagnostics and log records contain repository-relative paths and counts only, never file contents. ### Remaining Limitations @@ -165,8 +165,8 @@ To ensure that headline gains over BM25 (+50.5%) were not simply driven by large | Limitation | Impact | Severity | Planned Mitigation | | :--- | :--- | :---: | :--- | -| **Heuristic Abstention Failure** | 0.0% accuracy on no-gold tasks; always on in 1.1.0, disabled by default on the development branch (planned 1.2.0), where out-of-scope queries are therefore not detected | **HIGH** | Fine-tuned neural cross-encoder discriminator | -| **Indexing Exclusions** | Files > 500 KB (configurable via `max_file_size_bytes` on the development branch), unreadable files, or beyond `max_files` are not searchable. Silent in 1.1.0; reported in diagnostics on the development branch | **MEDIUM** | Configurable limit; future span-level indexing of large files | +| **Heuristic Abstention Failure** | 0.0% accuracy on no-gold tasks; always on in 1.1.0, disabled by default from 1.2.0, where out-of-scope queries are therefore not detected | **HIGH** | Fine-tuned neural cross-encoder discriminator | +| **Indexing Exclusions** | Files > 500 KB (configurable via `max_file_size_bytes` from 1.2.0), unreadable files, or beyond `max_files` are not searchable. Silent in 1.1.0; reported in diagnostics from 1.2.0 | **MEDIUM** | Configurable limit; future span-level indexing of large files | | **Deep Tail Recall Deficit** | Recall@20 lags RepoMap by -32.1% | **MEDIUM** | Multi-hop graph diffusion for tail ranks | | **`comment2context` Deficit** | MRR lags pure BM25 by -13.5% | **MEDIUM** | Dynamic BM25 up-weighting on colloquial inputs | | **Monolithic File Saturation** | 2k+ line files crowd budget | **MEDIUM** | AST block/span-level context slicing | @@ -177,7 +177,7 @@ To ensure that headline gains over BM25 (+50.5%) were not simply driven by large ## 9. Capabilities Not Implemented -These are frequently assumed but are **not** part of RepoNeedle (neither 1.1.0 nor the current development branch): +These are frequently assumed but are **not** part of RepoNeedle (neither 1.1.0 nor 1.2.0): | Capability | Status | | :--- | :--- | diff --git a/README.md b/README.md index 99bf400..7877329 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ RepoNeedle is a research-oriented project: its benchmark results, including wher ## 📌 Release Status -| Feature | 1.1.0 (released, PyPI) | Development branch (planned 1.2.0, **unreleased**) | +| Feature | 1.1.0 (released, PyPI) | 1.2.0 (**upcoming**; publication to PyPI pending) | | :--- | :---: | :---: | | CPU-first execution | ✅ | ✅ | | Python 3.10, 3.11, 3.12 | ✅ | ✅ | @@ -44,7 +44,7 @@ RepoNeedle is a research-oriented project: its benchmark results, including wher | HTTP / SSE MCP transport | Not implemented | Not implemented | | AST block slicing of large files | Not implemented | Not implemented | -Features marked for the development branch require installing from source (see below) until 1.2.0 is published. +Until 1.2.0 appears on [PyPI](https://pypi.org/project/reponeedle/), `pip install reponeedle` still installs 1.1.0; install from source (see below) to use the 1.2.0 features before then. --- @@ -53,23 +53,23 @@ Features marked for the development branch require installing from source (see b ### 1. Install ```bash -# Released version (1.1.0) from PyPI -pip install reponeedle +# From PyPI +pip install "reponeedle>=1.2.0" # core: Python API and CLI +pip install "reponeedle[mcp]>=1.2.0" # core + MCP server -# Development branch: adds --json, --anchors, the MCP server and indexing diagnostics +# Before 1.2.0 is published on PyPI: install from a clone git clone https://github.com/krishika08/RepoNeedle.git cd RepoNeedle -pip install -e . # core -pip install -e ".[mcp]" # core + MCP server +pip install -e ".[mcp]" ``` ### 2. Try the CLI ```bash -# Rank and pack context for a task (works with 1.1.0 and the development branch) +# Rank and pack context for a task (1.1.0 and later) reponeedle "where is the session cookie saved?" --repo ./my-project --budget 4000 -# Development branch: machine-readable JSON for scripts and agents +# 1.2.0: machine-readable JSON for scripts and agents reponeedle "fix token expiration in auth" --repo ./my-project --json ``` @@ -87,10 +87,10 @@ for f in result.selected_files: prompt_context = result.rendered_context # ready-to-paste markdown for an LLM ``` -### 4. Connect an MCP client (development branch) +### 4. Connect an MCP client (1.2.0) ```bash -pip install -e ".[mcp]" +pip install "reponeedle[mcp]>=1.2.0" claude mcp add reponeedle -- python -m reponeedle.mcp --repo /absolute/path/to/your/project ``` @@ -126,13 +126,14 @@ RepoNeedle is self-contained and requires standard Python dependencies (`numpy`, | What you need | Command | | :--- | :--- | -| Released 1.1.0 (Python API, Rich CLI) | `pip install reponeedle` | -| Development branch (adds `--json`, `--anchors`, indexing diagnostics) | `pip install -e .` (from a clone) | -| Development branch + MCP server | `pip install -e ".[mcp]"` | +| Core: Python API and CLI (incl. `--json`, `--anchors`, indexing diagnostics) | `pip install "reponeedle>=1.2.0"` | +| Core + MCP server | `pip install "reponeedle[mcp]>=1.2.0"` | +| Demo HTTP API dependencies (`fastapi`, `httpx`, `uvicorn`; only for the repository's `api.py`) | `pip install "reponeedle[api]"` | +| From a clone (before 1.2.0 is on PyPI, or for development) | `pip install -e ".[mcp]"` | | Test and development tools | `pip install -e ".[dev]"` | > [!NOTE] -> `pip install "reponeedle[mcp]"` from PyPI currently installs 1.1.0, which does not include the MCP server. Install from source for MCP support until 1.2.0 is published. +> 1.2.0 is **upcoming**. Until it is published on PyPI, the PyPI commands above fail to resolve `>=1.2.0` (an unpinned `pip install reponeedle` installs 1.1.0, which has no MCP server, `--json` or `--anchors`). Install from a clone in the meantime. > [!TIP] > On Python 3.10, `tomli>=2.0.1` is automatically installed via conditional dependency. On Python 3.11 and 3.12, native standard library `tomllib` is used with zero extra dependencies. @@ -174,7 +175,7 @@ else: #### Experimental Abstention (Opt-In) > [!WARNING] -> **Behavior change on the development branch (planned 1.2.0, unreleased)**: the selective abstention heuristic is **disabled by default**. In released 1.1.x it runs on every query. Because it is not production-calibrated (0 of 82 negative samples correctly abstained on the ARB v2 selective split), leaving it on gave a misleading impression that out-of-scope queries were being filtered. With it disabled, retrieval always returns ranked results (except for an empty repository) and **out-of-scope queries are not detected**. If you relied on the 1.1.x behavior, opt in explicitly as shown below. +> **Behavior change in 1.2.0**: the selective abstention heuristic is **disabled by default**. In released 1.1.x it runs on every query. Because it is not production-calibrated (0 of 82 negative samples correctly abstained on the ARB v2 selective split), leaving it on gave a misleading impression that out-of-scope queries were being filtered. With it disabled, retrieval always returns ranked results (except for an empty repository) and **out-of-scope queries are not detected**. If you relied on the 1.1.x behavior, opt in explicitly as shown below. ```python from reponeedle import RepoNeedleEngine, BudgetConfig, TaskType @@ -299,7 +300,7 @@ reponeedle "parser entry point" --repo ./my-project --json --max-file-size-bytes ### 4. Model Context Protocol (MCP) Server -*Development branch only (planned 1.2.0; install with `pip install -e ".[mcp]"`).* RepoNeedle includes a local **MCP server** that lets MCP-capable coding agents call it over STDIO. It is tested with the MCP Python SDK and a JSON-RPC STDIO handshake; it has not yet been validated in a live Claude Code, Cursor, Cline or Windsurf session. +*New in 1.2.0 (upcoming; install with `pip install "reponeedle[mcp]>=1.2.0"`, or `pip install -e ".[mcp]"` from a clone until 1.2.0 is on PyPI).* RepoNeedle includes a local **MCP server** that lets MCP-capable coding agents call it over STDIO. It is tested with the MCP Python SDK and a JSON-RPC STDIO handshake; it has not yet been validated in a live Claude Code, Cursor, Cline or Windsurf session. ```bash # Launch the MCP server pointing to your project @@ -325,7 +326,7 @@ For client configuration examples (Claude Code, Cursor), see the [MCP Integratio To maintain strict scientific integrity, **Official External Benchmark Results** are reported separately from **Independent Research Evaluations**. Dataset sizes, query counts ($N$), candidate pool properties, and evaluation methodologies are explicitly documented for every measurement. > [!IMPORTANT] -> **Which engine these numbers describe.** Parts A–C and the latency table in Part D come from the ARB v2 run of 2026-10-28, made with the engine at commit `8e00425` (tag `v0.1.0-baseline`); that code reproduced the published per-query records exactly on every query checked (all 82 selective and 53 of 345 positive queries). Engine changes made before the 1.1.0 release altered RepoNeedle's rankings, so these results do **not** describe released 1.1.0. The current engine (1.1.0, identical to the development branch) was re-measured on the same ARB v2 positive split on 2026-11-09: +> **Which engine these numbers describe.** Parts A–C and the latency table in Part D come from the ARB v2 run of 2026-10-28, made with the engine at commit `8e00425` (tag `v0.1.0-baseline`); that code reproduced the published per-query records exactly on every query checked (all 82 selective and 53 of 345 positive queries). Engine changes made before the 1.1.0 release altered RepoNeedle's rankings, so these results do **not** describe released 1.1.0. The current engine (1.1.0, and the pre-release 1.2.0 development branch of 2026-11-09, which produced identical rankings) was re-measured on the same ARB v2 positive split on 2026-11-09: > > | ARB v2 positive (N=345) | MRR | Recall@5 | Recall@10 | Recall@20 | BCY@4k | BCY@8k | > | :--- | :---: | :---: | :---: | :---: | :---: | :---: | @@ -410,7 +411,7 @@ Evaluated on the complete official Agent Retrieval Bench Selective Retrieval spl > **Heuristic Abstention Failure Analysis**: In production benchmark conditions, RepoNeedle's heuristic confidence gating ($Z$-score + repository symbol overlap) achieved **0% abstention accuracy (0/82)**. > - *Root Cause*: Heuristic confidence on the 82 no-gold queries ranged from `0.532` to `0.891` (5th–95th percentile `0.635`–`0.851`), far above the engine's default threshold of `0.12`. Natural GitHub issue descriptions filed against a repository inherently contain valid repository vocabulary. Counterfactual queries similarly contain ubiquitous software engineering terminology (`error`, `parse`, `client`, `handler`). > - *Recommendation*: Heuristic score thresholds are insufficient for reliable abstention in large codebases. Selective retrieval requires a dedicated, trained neural discriminator rather than surface lexical statistics. -> - *Consequence*: In released 1.1.0 the heuristic is always on. On the development branch (planned 1.2.0, unreleased) it is **disabled by default** and only applied when explicitly enabled. See [Experimental Abstention](#experimental-abstention-opt-in). +> - *Consequence*: In released 1.1.0 the heuristic is always on. From 1.2.0 it is **disabled by default** and only applied when explicitly enabled. See [Experimental Abstention](#experimental-abstention-opt-in). --- @@ -460,7 +461,7 @@ Empirically measured across the full Agent Retrieval Bench evaluation suite: ### Part E: System Architecture & Deployment Status - **CPU Native Architecture**: RepoNeedle runs entirely in Python/NumPy/SciPy/NetworkX without requiring CUDA GPUs or heavy deep learning runtime dependencies. -- **Release Status**: **1.1.0 is published on PyPI (2026-11-03)**, with the default `legacy` ranker and the opt-in `change_impact` ranker. The development branch adds the features listed in [Release Status](#-release-status) and is planned for 1.2.0; it is **not yet released**. +- **Release Status**: **1.1.0 is published on PyPI (2026-11-03)**, with the default `legacy` ranker and the opt-in `change_impact` ranker. 1.2.0 adds the features listed in [Release Status](#-release-status); it is **upcoming** (this repository's version; PyPI publication pending). - **Abstention during these evaluations**: Parts A–C, the Part D latency table and Part F were measured with code in which the abstention heuristic was always on. A full re-run on ARB v2 (2026-11-09) verified that the heuristic would abstain on **0 of 345** positive queries and that selected files are identical with and without it, so the new opt-in default does not change any ARB v2 result. See [docs/BENCHMARK_CONFIGURATION.md](docs/BENCHMARK_CONFIGURATION.md). --- diff --git a/docs/ANNOUNCEMENT.md b/docs/ANNOUNCEMENT.md index 0af78ea..9145888 100644 --- a/docs/ANNOUNCEMENT.md +++ b/docs/ANNOUNCEMENT.md @@ -2,8 +2,8 @@ > [!CAUTION] > **Historical document with corrections (corrected 2026-11-09).** This announcement was written for the **v0.1.0 research preview** (October 2026). It is kept for the record, but several of its original claims were not supported by later evaluation and have been corrected in place below: -> - **Selective abstention**: The original text described a "calibrated" abstention mechanism with perfect accuracy. That figure came from a synthetic benchmark containing only **3** selective samples. On the official Agent Retrieval Bench v2 selective split (**N = 82** no-gold queries) the heuristic correctly abstained on **0 of 82 (0.0%)**. The heuristic is uncalibrated and experimental, and on the current development branch (planned for 1.2.0, unreleased) it is **disabled by default**. -> - **Benchmark table**: The table below reports the v0.1.0 synthetic **RepoNeedle-Bench** run (15 samples). The stored result file (`benchmarks/results/benchmark_results.json`) is **not reproducible** by any later code version (1.1.0 or the current branch); current re-run values are shown alongside. +> - **Selective abstention**: The original text described a "calibrated" abstention mechanism with perfect accuracy. That figure came from a synthetic benchmark containing only **3** selective samples. On the official Agent Retrieval Bench v2 selective split (**N = 82** no-gold queries) the heuristic correctly abstained on **0 of 82 (0.0%)**. The heuristic is uncalibrated and experimental, and from 1.2.0 (upcoming) it is **disabled by default**. +> - **Benchmark table**: The table below reports the v0.1.0 synthetic **RepoNeedle-Bench** run (15 samples). The stored result file (`benchmarks/results/benchmark_results.json`) is **not reproducible** by any later code version (1.1.0 or the pre-release 1.2.0 development branch); current re-run values are shown alongside. > - **Latency**: Sub-millisecond latency was measured on tiny synthetic corpora. On real repositories (ARB v2) mean query latency is ~318 ms (see README, Part D). > > For current, evidence-backed results see [README.md](../README.md), [CLAIMS_AND_EVIDENCE.md](../CLAIMS_AND_EVIDENCE.md), [KNOWN_LIMITATIONS.md](../KNOWN_LIMITATIONS.md) and [BENCHMARK_CONFIGURATION.md](BENCHMARK_CONFIGURATION.md). @@ -51,7 +51,7 @@ Evaluated on the synthetic **RepoNeedle-Bench** (`benchmarks/runner.py`): **15 s | **BM25 (Sparse Lexical)** | 0.819 | 0.417 | 0.861 | 0.784 | 693.6 | 1/3 | 0.15 ms | | **RepoNeedle (Adaptive + Budget)** | 0.917 | 0.528 | 1.000 | 0.936 | 581.0 | 3/3 | 0.80 ms | -**Re-run on 2026-11-09** with the same harness and data. Identical values from the released 1.1.0 code and from the current development branch (experimental abstention explicitly enabled for the 3 selective samples only): +**Re-run on 2026-11-09** with the same harness and data. Identical values from the released 1.1.0 code and from the pre-release 1.2.0 development branch (experimental abstention explicitly enabled for the 3 selective samples only): | Retrieval System | MRR | Recall@1 | Recall@3 | nDCG@3 | Irrelevant Tokens (4k) | Selective (N=3) | | :--- | :---: | :---: | :---: | :---: | :---: | :---: | diff --git a/docs/BENCHMARK_CONFIGURATION.md b/docs/BENCHMARK_CONFIGURATION.md index ab1ff1d..5035044 100644 --- a/docs/BENCHMARK_CONFIGURATION.md +++ b/docs/BENCHMARK_CONFIGURATION.md @@ -1,6 +1,6 @@ # Benchmark Configuration & Reproducibility -This document records **how each benchmark and research script configures RepoNeedle**, which historical results were measured under which defaults, and known reproducibility gaps. It exists because the development branch (planned 1.2.0, unreleased) changed a default that some evaluations depend on. +This document records **how each benchmark and research script configures RepoNeedle**, which historical results were measured under which defaults, and known reproducibility gaps. It exists because 1.2.0 (upcoming; PyPI publication pending) changed a default that some evaluations depend on. *Last audited: 2026-11-09. Machine-checked by `tests/test_documentation_consistency.py`.* @@ -11,7 +11,7 @@ This document records **how each benchmark and research script configures RepoNe | Version | Selective abstention heuristic | | :--- | :--- | | ≤ 1.1.0 (released) | **Always on.** Every `retrieve()` call could abstain (return no files) on heuristic grounds. | -| Development branch (planned 1.2.0) | **Off by default.** Enabled only via `BudgetConfig(enable_experimental_abstention=True)`, `retrieve(..., enable_experimental_abstention=True)`, the CLI flag `--enable-experimental-abstention`, or the MCP argument of the same name. | +| 1.2.0 | **Off by default.** Enabled only via `BudgetConfig(enable_experimental_abstention=True)`, `retrieve(..., enable_experimental_abstention=True)`, the CLI flag `--enable-experimental-abstention`, or the MCP argument of the same name. | The heuristic itself (formula, threshold semantics, default threshold `0.12`) is unchanged. Empty repositories always return `abstained=True` regardless of the flag, because there are no candidates. @@ -97,12 +97,13 @@ The same script reported that current confidence values (2/82) and top-20 lists | :--- | :---: | :---: | | Commit `8e00425` (tag `v0.1.0-baseline`, 2026-10-28; produced the historical reports) | **82 / 82** (bit-identical) | **53 / 53** | | Released 1.1.0 (`HEAD` of `main`) | 2 / 82 (max diff 0.031) | 1 / 53 | -| Development branch (planned 1.2.0) | identical to 1.1.0 (max diff 0.0) | identical to 1.1.0 (53 / 53) | +| Pre-release 1.2.0 development branch (2026-11-09) | identical to 1.1.0 (max diff 0.0) | identical to 1.1.0 (53 / 53) | Conclusions: - The evaluation pipeline and data are sound: the producing engine reproduces the published records exactly on every query checked. - Engine changes made between 2026-10-28 and the 1.1.0 release changed RepoNeedle's rankings. **README Part A and the ARB-derived claims in `CLAIMS_AND_EVIDENCE.md` describe the 2026-10-28 engine.** - Milestones 1A–1C (CLI JSON, anchors, MCP, abstention opt-in, indexing diagnostics) changed no ranking result: the development branch is bit-identical to 1.1.0 on every query compared. +- Changes merged into 1.2.0 after these measurements were not re-measured. They do not touch the ranking path used here: all benchmark scripts build the engine with `from_documents`, while the outside-repository link check applies only to `from_directory`; the CLI anchor path handling is CLI-only; and the `change_impact` change affects the reported task label only (ranking equality is covered by `tests/test_release_compat.py`). ### 7.3 Current-engine re-measurement (ARB v2 positive, N=345) diff --git a/docs/MCP_INTEGRATION.md b/docs/MCP_INTEGRATION.md index ee949f6..3824e41 100644 --- a/docs/MCP_INTEGRATION.md +++ b/docs/MCP_INTEGRATION.md @@ -1,6 +1,6 @@ # RepoNeedle Model Context Protocol (MCP) Integration Guide -**Status**: Available on the development branch; **planned for RepoNeedle 1.2.0 (not yet released)**. The published PyPI release (1.1.0) does **not** include the MCP server. +**Status**: New in RepoNeedle 1.2.0 (**upcoming**; PyPI publication pending). The earlier PyPI release 1.1.0 does **not** include the MCP server. **Transport**: STDIO JSON-RPC 2.0 (HTTP/SSE transport is not implemented) **SDK Requirement**: `mcp>=1.28,<2` (Optional dependency group: `mcp`) **Validation**: Verified with the MCP Python SDK (in-process tool calls) and a raw JSON-RPC STDIO handshake (`initialize`, `tools/list`, `tools/call`) in the test suite. **Not yet validated in a live Claude Code, Cursor, Cline or Windsurf session**; the client configuration examples below follow each client's documented MCP configuration format. @@ -23,16 +23,20 @@ The **Model Context Protocol (MCP)** is an open specification enabling AI coding ## 2. Installation -RepoNeedle core requires Python 3.10+. MCP support is currently available **from source only** (development branch, planned for 1.2.0): +RepoNeedle core requires Python 3.10+. Install RepoNeedle 1.2.0 or later with the optional `mcp` extra: ```bash -git clone https://github.com/krishika08/RepoNeedle.git -cd RepoNeedle -pip install -e ".[mcp]" +pip install "reponeedle[mcp]>=1.2.0" ``` > [!NOTE] -> `pip install "reponeedle[mcp]"` from PyPI currently installs **1.1.0**, which does not contain the MCP server or the `mcp` extra (pip only warns about the unknown extra). Use the PyPI command once a release that includes MCP support has been published. +> 1.2.0 is **upcoming**. Until it is published on PyPI, the command above cannot be resolved (1.1.0, the latest version on PyPI until then, contains neither the MCP server nor the `mcp` extra). In the meantime, install from a clone: +> +> ```bash +> git clone https://github.com/krishika08/RepoNeedle.git +> cd RepoNeedle +> pip install -e ".[mcp]" +> ``` To verify that the MCP server executable is available: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d6c2f9b..623eaea 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,8 +1,8 @@ # RepoNeedle Research & Engineering Roadmap -This roadmap is grounded in empirical limitations observed during benchmark evaluations. Status labels distinguish what has been **released**, what exists only on the **development branch**, and what is **not implemented**. +This roadmap is grounded in empirical limitations observed during benchmark evaluations. Status labels distinguish what has been **released**, what is in the **upcoming 1.2.0** release (this repository's current version; PyPI publication pending), and what is **not implemented**. -*Last reconciled with the source code: 2026-11-09.* +*Last reconciled with the source code: 2026-11-10.* --- @@ -17,14 +17,15 @@ This roadmap is grounded in empirical limitations observed during benchmark eval - [x] Selective abstention heuristic, **always on in 1.1.0**. It is **experimental and uncalibrated**: on the ARB v2 selective split it correctly abstained on **0 of 82** no-gold queries. - [x] Hugging Face release assets prepared in `hf_artifacts/` (dataset card, Gradio Playground Space bundling the 1.1.0 engine, where abstention is always on). -## Development branch (planned for 1.2.0, not yet released) +## Upcoming: RepoNeedle 1.2.0 (PyPI publication pending) - [x] CLI machine-readable output (`--json`) and anchor files (`--anchors`). - [x] Native MCP server over STDIO (`reponeedle-mcp`, optional `mcp` extra) with `reponeedle_retrieve`, `reponeedle_change_impact` and `reponeedle_inspect`. Verified with the MCP Python SDK and a raw JSON-RPC STDIO handshake; **not yet validated in a live Claude Code or Cursor session**. - [x] Experimental abstention made **opt-in (disabled by default)**, with explicit diagnostics (`experimental: true`, `calibrated: false`) on every result. - [x] Indexing diagnostics: configurable file-size limit (`max_file_size_bytes`, default 500,000 bytes) with recorded skipped and unreadable paths. +- [x] Indexing security: files reached through symlinks (or Windows junctions) that resolve outside the repository root are no longer read; they are recorded in the diagnostics instead. - [x] Benchmark reproducibility audit: explicit abstention policy per script ([BENCHMARK_CONFIGURATION.md](BENCHMARK_CONFIGURATION.md)). -Test coverage is measured, not assumed: on the development branch, line coverage of the `reponeedle` package is **87%** (`pytest --cov=reponeedle`, 2026-11-09). Earlier claims of "100% test coverage" were incorrect. +Test coverage is measured, not assumed: on the pre-release 1.2.0 development branch, line coverage of the `reponeedle` package is **87%** (`pytest --cov=reponeedle`, 2026-11-09). Earlier claims of "100% test coverage" were incorrect. ## Not implemented | Capability | Status | diff --git a/pyproject.toml b/pyproject.toml index 5d9f54b..347232e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "reponeedle" -version = "1.1.0" +version = "1.2.0" description = "Token-Budget-Aware, Workflow-Adaptive Repository Context Retrieval for AI Coding Agents" readme = "README.md" license = "MIT" diff --git a/reponeedle/__init__.py b/reponeedle/__init__.py index dd22f29..28335b1 100644 --- a/reponeedle/__init__.py +++ b/reponeedle/__init__.py @@ -23,7 +23,7 @@ CodeSpanExtractor, ) -__version__ = "1.1.0" +__version__ = "1.2.0" __all__ = [ "RepoNeedleEngine", "FileDoc", diff --git a/reports/release_audit_1.2.0/PROPOSED_CHANGELOG_1.2.0.md b/reports/release_audit_1.2.0/PROPOSED_CHANGELOG_1.2.0.md index 88ccccf..a28b4fb 100644 --- a/reports/release_audit_1.2.0/PROPOSED_CHANGELOG_1.2.0.md +++ b/reports/release_audit_1.2.0/PROPOSED_CHANGELOG_1.2.0.md @@ -1,5 +1,7 @@ # RepoNeedle 1.2.0 — Proposed Changelog (draft, not yet released) +> **Superseded** by the root-level [CHANGELOG.md](../../CHANGELOG.md), which was reviewed against the code and adds the POSIX anchor fix, backward-compatibility and migration notes. This draft is kept for the audit record. + ## Highlights - **Native MCP server** (`reponeedle-mcp`, optional `mcp` extra) for MCP-capable coding agents over STDIO. diff --git a/tests/test_documentation_consistency.py b/tests/test_documentation_consistency.py index a527f3e..0192810 100644 --- a/tests/test_documentation_consistency.py +++ b/tests/test_documentation_consistency.py @@ -177,19 +177,36 @@ def test_package_version_is_consistent(self): assert PYPROJECT["project"]["version"] == reponeedle.__version__ def test_unreleased_version_is_never_described_as_released(self): - version = tuple(int(x) for x in PYPROJECT["project"]["version"].split(".")[:2]) - if version >= (1, 2): - pytest.skip("1.2 is the packaged version; release wording is no longer forward-looking") + # Any minor version newer than the packaged one is unreleased and must carry a qualifier. + packaged = tuple(int(x) for x in PYPROJECT["project"]["version"].split(".")[:2]) qualifier = re.compile( r"planned|unreleased|not yet|development branch|until|once|upcoming|pending|candidate", re.I ) offenders = [] for doc in CURRENT_DOCS: for no, line in enumerate(_read(doc).splitlines(), start=1): - if re.search(r"\b1\.2(\.0)?\b", line) and not qualifier.search(line): - if re.search(r"releas|availab|ship|production|since|in 1\.2", line, re.I): + newer = [m for m in re.findall(r"\b1\.(\d+)(?:\.\d+)?\b", line) if (1, int(m)) > packaged] + if newer and not qualifier.search(line): + if re.search(r"releas|availab|ship|production|since|in 1\.\d", line, re.I): offenders.append(f"{doc.relative_to(ROOT)}:{no}: {line.strip()[:120]}") - assert not offenders, "Lines presenting unreleased 1.2 as released:\n" + "\n".join(offenders) + assert not offenders, "Lines presenting an unreleased version as released:\n" + "\n".join(offenders) + + def test_packaged_version_is_not_described_as_planned(self): + # After a version bump, pre-bump wording ("planned for 1.2.0", "development branch only") + # must not remain in the current documentation. + major_minor = ".".join(PYPROJECT["project"]["version"].split(".")[:2]) + stale = re.compile( + rf"planned (for )?{re.escape(major_minor)}\b|development branch only|" + rf"until {re.escape(major_minor)}(\.0)? is published, install from source", + re.I, + ) + offenders = [ + f"{doc.relative_to(ROOT)}:{no}: {line.strip()[:120]}" + for doc in CURRENT_DOCS + for no, line in enumerate(_read(doc).splitlines(), start=1) + if stale.search(line) + ] + assert not offenders, "Pre-bump wording for the packaged version remains:\n" + "\n".join(offenders) def test_no_hard_coded_test_counts_in_user_docs(self): for doc in [ROOT / "README.md", *sorted((ROOT / "docs").glob("*.md"))]: From 2b4da11e032d2cfae932e6656269f42f69ba67f6 Mon Sep 17 00:00:00 2001 From: Krishika <203898617+krishika08@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:33:29 +0530 Subject: [PATCH 2/2] Never traverse directory links during indexing; bound directory traversal RepoNeedleEngine.from_directory walked with os.walk(followlinks=False), which decides via os.path.islink(). That is False for Windows NTFS junctions, so junctions were descended into: - A junction pointing to an ancestor directory caused repeated traversal until the path length limit (reproduced: 83 directory visits for 3 real directories, each file indexed up to 27 times). - A junction pointing outside the repository caused outside directories to be listed (15 in the reproduction), and outside file names were recorded in indexing diagnostics. Outside file contents were already not read (file-level containment). On Linux and macOS, directory symlinks were already not followed. Fix (from_directory traversal only; ranking, packing, abstention and from_documents are unchanged): - Directory symlinks and junctions are never traversed on any platform. is_directory_link() uses os.lstat (S_ISLNK, or the mount-point reparse tag on Windows), so Python 3.10-3.12 share one code path. It fails closed: an entry that cannot be inspected is treated as a link. - Each physical directory (st_dev, st_ino) is entered at most once, so cycles that are not links (e.g. bind mounts) are also bounded. - Skipped directories are recorded by in-repository path only: skipped_directories, skipped_directory_paths and skipped_directories_outside_repository (new, defaulted fields). A count-only warning, CLI notice and MCP startup note are emitted for links resolving outside the repository. - Files under an in-repository link target are indexed once, under their real path. A directory reachable only through a link is not indexed (a behaviour change on Windows only; documented). Tests: platform-neutral directory-link tests in test_indexing_security.py (real symlinks on Linux in the strict no-skip CI job; junction fallback on Windows), including a simulated repeated-physical-identity test (os.stat patched; not a real bind mount) and a fail-closed detection test. Windows-only junction tests are in test_windows_junctions.py, which is not part of the strict job. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- KNOWN_LIMITATIONS.md | 1 + README.md | 1 + docs/MCP_INTEGRATION.md | 4 ++ reponeedle/cli.py | 5 ++ reponeedle/engine.py | 78 +++++++++++++++++++- reponeedle/mcp/server.py | 4 +- reponeedle/models.py | 10 +++ tests/test_indexing_security.py | 124 +++++++++++++++++++++++++++++++- tests/test_windows_junctions.py | 78 ++++++++++++++++++++ 10 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 tests/test_windows_junctions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 210792b..c63637d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ This release does **not** change retrieval quality. No ranking algorithm, fusion - skipped oversized files (paths only); - unreadable files; - links resolving outside the repository; + - directory links and junctions that were not traversed; - whether the `max_files` cap was reached. - `RetrievalResult.abstention` (`AbstentionDiagnostics`): whether the heuristic was applied, with `experimental: true` and `calibrated: false`. - `BudgetConfig.enable_experimental_abstention` and `retrieve(..., enable_experimental_abstention=...)`. @@ -68,6 +69,7 @@ This release does **not** change retrieval quality. No ranking algorithm, fusion - **Indexing no longer reads files outside the repository through links.** `from_directory` resolves every file before opening it. Files whose canonical location is outside the repository root (symlinks, and junctions on Windows) are skipped without reading the target. Each skipped link is listed by its in-repository path in `indexing_diagnostics.symlink_outside_repository_paths` (reason `symlink_outside_repository`). - In 1.1.0 such files were indexed, and their contents could be returned by retrieval, including through any tool built on it. - Links resolving inside the repository are still indexed. +- **Directory links are never traversed, and traversal is bounded.** Directory symlinks and Windows junctions are not followed on any platform, and no physical directory is entered twice. In 1.1.0, Windows junctions were followed (`os.path.islink` is False for them): a junction to an ancestor directory caused repeated traversal and duplicate index entries until the path length limit, and a junction to an outside directory caused its listings to be traversed. Skipped directories are recorded by in-repository path only (`skipped_directory_paths`, `skipped_directories_outside_repository`), with a count-only warning for those resolving outside the repository. Files under an in-repository link target are indexed once, under their real path. A directory reachable **only** through a directory link (for example a link or junction to a sibling checkout, or to a directory excluded by ignore rules) is therefore not indexed. On Windows this is a change from 1.1.0; on Linux and macOS directory symlinks were already not followed. - CLI anchors use the same canonical containment check: an anchor that links outside the repository is rejected. ### Fixed @@ -92,9 +94,10 @@ The core runtime dependencies are unchanged from 1.1.0: `numpy`, `scipy`, `netwo - All 1.1.0 public call signatures, `RetrievalResult` fields and CLI options keep working. New parameters are optional keywords, and new result fields are additions. - The default ranker (`legacy`) and all ranking, fusion, routing and packing defaults are unchanged. On every ARB v2 query compared (82 selective and 53 positive), the pre-release development branch produced rankings bit-identical to 1.1.0. Later changes in 1.2.0 do not touch the ranking path; they were not re-measured. - `change_impact` results for `AUTO` queries report the router-classified task, as in 1.1.0. This is now covered by regression tests. -- Behaviour differs from 1.1.0 in three places: +- Behaviour differs from 1.1.0 in four places: - abstention is off by default; - links resolving outside the repository are no longer indexed; + - directory links and Windows junctions are not traversed (on Linux and macOS this was already the case); - skipped files produce a logged warning. ### Migrating from 1.1.x @@ -104,7 +107,7 @@ The core runtime dependencies are unchanged from 1.1.0: `numpy`, `scipy`, `netwo - Python, per call: `engine.retrieve(..., enable_experimental_abstention=True)` - CLI: `--enable-experimental-abstention` - MCP: `"enable_experimental_abstention": true` -- **If you relied on indexing files through links that point outside the repository:** move or copy those files into the repository, or point `--repo` / `from_directory` at the directory that actually contains them. Check `engine.indexing_diagnostics.symlink_outside_repository_paths` to see what was skipped. +- **If you relied on indexing files through links that point outside the repository** (file links, directory symlinks or Windows junctions): move or copy those files into the repository, or point `--repo` / `from_directory` at the directory that actually contains them. Check `engine.indexing_diagnostics.symlink_outside_repository_paths` and `skipped_directory_paths` to see what was skipped. - **If large files are skipped:** raise the limit with `max_file_size_bytes` (Python) or `--max-file-size-bytes` (CLI and MCP). - **If the new indexing warning is unwanted:** adjust the `reponeedle.engine` logger level with the standard `logging` configuration. diff --git a/KNOWN_LIMITATIONS.md b/KNOWN_LIMITATIONS.md index 39385ad..bc09846 100644 --- a/KNOWN_LIMITATIONS.md +++ b/KNOWN_LIMITATIONS.md @@ -125,6 +125,7 @@ In repositories containing large monolithic source files (>2,000 lines of code, 1. **File-size limit**: files **strictly larger** than `max_file_size_bytes` (default `500_000` bytes) are excluded. A file exactly at the limit is indexed. The limit is configurable (`from_directory(..., max_file_size_bytes=...)`, CLI/MCP `--max-file-size-bytes`) and must be a positive integer. It is never raised automatically. 2. **Unreadable files**: files whose size or contents cannot be read are excluded (including broken links and link loops). 2b. **Links resolving outside the repository** (1.2.0): files whose canonical location (following symlinks, and junctions on Windows) is outside the repository root are excluded without reading the target and recorded by link path (`symlink_outside_repository_paths`). In released 1.1.0 such files were read and indexed. +2c. **Directory links** (1.2.0): directory symlinks and Windows junctions are never traversed, on every platform, and no physical directory is entered twice. They are recorded by in-repository path (`skipped_directory_paths`; `skipped_directories_outside_repository` counts those resolving outside the root). Files under a link target inside the repository are indexed once, under their real path. A directory reachable only through a link (for example a link to a sibling checkout) is therefore not indexed. On Linux and macOS, directory symlinks were not followed in 1.1.0 either. On Windows, 1.1.0 followed junctions: a junction pointing to an ancestor directory caused repeated traversal (duplicate entries) until the path length limit, and the files under a junction to an outside directory were read and indexed (see 2b). 3. **`max_files` cap**: indexing stops after `max_files` (default `3000`) files. 4. **Ignore rules and extensions**: `.gitignore` entries, built-in ignore patterns (`node_modules`, `dist`, `build`, …) and unsupported extensions are excluded by design and are **not** reported as skipped. diff --git a/README.md b/README.md index 7877329..6534749 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ print(diag.indexed_files, diag.skipped_large_files, diag.max_file_size_bytes) print(diag.skipped_paths) # e.g. ["generated/large_module.py", "vendor/huge_source.cpp"] print(diag.unreadable_paths) # files that could not be read print(diag.symlink_outside_repository_paths) # links resolving outside the repo (target never read) +print(diag.skipped_directory_paths) # directory links/junctions not traversed (never followed) print(diag.max_files_reached) # True if indexing stopped at max_files (default 3000) ``` diff --git a/docs/MCP_INTEGRATION.md b/docs/MCP_INTEGRATION.md index 3824e41..3459247 100644 --- a/docs/MCP_INTEGRATION.md +++ b/docs/MCP_INTEGRATION.md @@ -271,6 +271,9 @@ Takes no arguments (`{}`). "unreadable_paths": [], "skipped_symlink_outside_repository": 0, "symlink_outside_repository_paths": [], + "skipped_directories": 0, + "skipped_directory_paths": [], + "skipped_directories_outside_repository": 0, "max_files": 3000, "max_files_reached": false }, @@ -289,6 +292,7 @@ The `indexing` object reports what the startup index excluded, so an agent can t - `skipped_paths` / `skipped_large_files`: supported-extension files strictly larger than `max_file_size_bytes` (set with `--max-file-size-bytes`). A file exactly at the limit is indexed. - `unreadable_paths` / `skipped_unreadable_files`: files that could not be read (including broken links and link loops). - `symlink_outside_repository_paths` / `skipped_symlink_outside_repository`: in-repository links whose target resolves outside the repository root. Their targets are never read; only the link path is listed. +- `skipped_directory_paths` / `skipped_directories`: directories that were not traversed. Directory symlinks and Windows junctions are never followed (files under a link target inside the repository are indexed once, under their real path), and a physical directory is never entered twice, so link cycles cannot cause repeated traversal. `skipped_directories_outside_repository` counts the links that resolve outside the repository root (or cannot be resolved); their contents are never listed or read. - `max_files_reached`: `true` if indexing stopped at the `max_files` cap. Paths are repository-relative with forward slashes and sorted. File contents are never included. Files excluded by `.gitignore`, built-in ignore patterns, or unsupported extensions are excluded by design and are not listed. Diagnostics describe the index built at server startup; restart the server to refresh them. diff --git a/reponeedle/cli.py b/reponeedle/cli.py index 029880c..fad83b8 100644 --- a/reponeedle/cli.py +++ b/reponeedle/cli.py @@ -204,6 +204,11 @@ def _indexing_notice_lines(diag) -> List[str]: ) for p in diag.symlink_outside_repository_paths[:_MAX_SKIPPED_PATHS_SHOWN]: lines.append(f" - {p}") + if diag.skipped_directories_outside_repository: + lines.append( + f"{diag.skipped_directories_outside_repository} directory link(s) resolve outside the repository " + f"(or cannot be resolved) and were not traversed (see skipped_directory_paths in --json)." + ) if diag.max_files_reached: lines.append(f"Indexing stopped at the max_files limit ({diag.max_files}); remaining files were not indexed.") return lines diff --git a/reponeedle/engine.py b/reponeedle/engine.py index b55aba7..2266f7e 100644 --- a/reponeedle/engine.py +++ b/reponeedle/engine.py @@ -8,6 +8,7 @@ import logging import pathlib import numbers +import stat from typing import List, Optional, Union, Dict, Set import numpy as np import pathspec @@ -80,6 +81,26 @@ def resolve_within_root(path: str, real_root: str) -> Optional[str]: return resolved if is_path_within_root(resolved, real_root) else None +def is_directory_link(path: str) -> bool: + """True if `path` is a symlink or a Windows junction (mount-point reparse point). + + ``os.walk(followlinks=False)`` relies on ``os.path.islink``, which is False for Windows + junctions, so junctions would otherwise be descended into. The check uses ``os.lstat`` + directly (the same reparse-tag test as ``os.path.isjunction``, which Python < 3.12 lacks), so + every Python version takes the same path. Fail-closed: if the entry cannot be inspected it + counts as a link, and the directory is not traversed. (``os.path.islink``/``isjunction`` + would instead report False on errors.)""" + try: + st = os.lstat(path) + except (OSError, ValueError): + return True + if stat.S_ISLNK(st.st_mode): + return True + if os.name == "nt": + return getattr(st, "st_reparse_tag", 0) == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003) + return False + + def validate_max_file_size_bytes(value) -> int: """ Validates a file-size limit. The contract is a strictly positive integer number of bytes. @@ -292,8 +313,18 @@ def from_directory( max_files_reached = False # Canonical repository root: every indexed file must resolve beneath it. real_root = os.path.realpath(repo_dir) - - for root, dirs, files in os.walk(repo_dir): + # Directory traversal is bounded by the real directory tree: links are never followed and + # each physical directory (st_dev, st_ino) is entered at most once. + skipped_dirs: List[str] = [] + skipped_dirs_outside = 0 + visited_dirs: Set = set() + try: + root_st = os.stat(repo_dir) + visited_dirs.add((root_st.st_dev, root_st.st_ino)) + except OSError: + pass + + for root, dirs, files in os.walk(repo_dir, followlinks=False): # Prune ignored directories rel_root = os.path.relpath(root, repo_dir).replace("\\", "/") if rel_root == ".": @@ -305,6 +336,37 @@ def from_directory( and d not in DEFAULT_IGNORE_PATTERNS ] + # Prune directory links / junctions, cycles and directories outside the canonical root. + # Only the in-repository path is recorded; link targets are never listed or read. + kept_dirs = [] + for d in dirs: + child = os.path.join(root, d) + rel_child = f"{rel_root}/{d}" if rel_root else d + outside = False + if is_directory_link(child): + keep = False + try: + outside = resolve_within_root(child, real_root) is None + except (OSError, RuntimeError, ValueError): # broken link or loop + outside = True + else: + # A plain directory beneath an in-root directory is itself in-root; the + # (st_dev, st_ino) set catches cycles that are not links (e.g. bind mounts). + try: + child_st = os.stat(child) + key = (child_st.st_dev, child_st.st_ino) + keep = key not in visited_dirs + if keep: + visited_dirs.add(key) + except OSError: + keep = False + if keep: + kept_dirs.append(d) + else: + skipped_dirs.append(rel_child) + skipped_dirs_outside += int(outside) + dirs[:] = kept_dirs + for file in files: rel_path = os.path.relpath(os.path.join(root, file), repo_dir).replace("\\", "/") if spec.match_file(rel_path): @@ -392,6 +454,7 @@ def from_directory( skipped_large.sort() unreadable.sort() outside_root.sort() + skipped_dirs.sort() engine.indexing_diagnostics = IndexingDiagnostics( source="directory", indexed_files=len(engine.docs), @@ -402,6 +465,9 @@ def from_directory( unreadable_paths=unreadable, skipped_symlink_outside_repository=len(outside_root), symlink_outside_repository_paths=outside_root, + skipped_directories=len(skipped_dirs), + skipped_directory_paths=skipped_dirs, + skipped_directories_outside_repository=skipped_dirs_outside, max_files=max_files, max_files_reached=max_files_reached, ) @@ -413,6 +479,14 @@ def from_directory( len(outside_root), ) + if skipped_dirs_outside: + logger.warning( + "RepoNeedle did not traverse %d directory link(s) that resolve outside the repository " + "root (or cannot be resolved); their contents were not listed or read. See indexing " + "diagnostics (skipped_directory_paths) for the in-repository paths.", + skipped_dirs_outside, + ) + # Summaries only (counts + limit): no paths or contents in log records. if skipped_large: logger.warning( diff --git a/reponeedle/mcp/server.py b/reponeedle/mcp/server.py index ad73d44..630cd23 100644 --- a/reponeedle/mcp/server.py +++ b/reponeedle/mcp/server.py @@ -63,12 +63,14 @@ def create_mcp_server( ) diag = engine.indexing_diagnostics if (diag.skipped_large_files or diag.skipped_unreadable_files or diag.max_files_reached - or diag.skipped_symlink_outside_repository): + or diag.skipped_symlink_outside_repository or diag.skipped_directories_outside_repository): sys.stderr.write( f"[reponeedle-mcp] Indexing exclusions: {diag.skipped_large_files} file(s) over " f"{diag.max_file_size_bytes} bytes, {diag.skipped_unreadable_files} unreadable" + (f", {diag.skipped_symlink_outside_repository} linked outside the repository (not read)" if diag.skipped_symlink_outside_repository else "") + + (f", {diag.skipped_directories_outside_repository} directory link(s) outside the repository " + f"(not traversed)" if diag.skipped_directories_outside_repository else "") + (f", stopped at max_files={diag.max_files}" if diag.max_files_reached else "") + ". Call reponeedle_inspect for the skipped paths.\n" ) diff --git a/reponeedle/models.py b/reponeedle/models.py index 2778b40..a2c96d5 100644 --- a/reponeedle/models.py +++ b/reponeedle/models.py @@ -111,6 +111,16 @@ class IndexingDiagnostics(BaseModel): # is recorded; the target path and contents are never read or stored. skipped_symlink_outside_repository: int = 0 symlink_outside_repository_paths: List[str] = Field(default_factory=list) + # Directories that were not traversed. Directory symlinks and Windows junctions are never + # followed (on every platform); files beneath an in-repository link target are still indexed + # through their real path. Directories that would revisit an already-traversed directory + # (cycles) or whose canonical location is outside the repository root are also skipped. + # Only the in-repository path is recorded; link targets are never listed or stored. + skipped_directories: int = 0 + skipped_directory_paths: List[str] = Field(default_factory=list) + # Subset of skipped_directories whose canonical location is outside the repository root + # (or cannot be resolved, e.g. broken links). + skipped_directories_outside_repository: int = 0 max_files: Optional[int] = None max_files_reached: bool = False diff --git a/tests/test_indexing_security.py b/tests/test_indexing_security.py index a5e5f07..1f9e3bb 100644 --- a/tests/test_indexing_security.py +++ b/tests/test_indexing_security.py @@ -102,11 +102,131 @@ def test_directory_link_to_outside_is_not_indexed(self, layout): repo, outside = layout kind = _dir_link(repo / "linked", outside) engine = RepoNeedleEngine.from_directory(str(repo)) + diag = engine.indexing_diagnostics assert not any(d.path.startswith("linked/") for d in engine.docs) - if kind == "junction": # os.walk descends junctions: the escape is detected and recorded - assert engine.indexing_diagnostics.symlink_outside_repository_paths == ["linked/secret.py"] + # Directory links (symlinks and junctions alike) are never traversed: only the link's own + # in-repository path is recorded, so names of files outside the repository are not listed. + assert diag.skipped_directory_paths == ["linked"], kind + assert diag.skipped_directories == 1 + assert diag.skipped_directories_outside_repository == 1 + assert diag.symlink_outside_repository_paths == [] + _assert_sentinel_unreachable(engine) + + def test_nested_directories_are_indexed_normally(self, layout): + repo, _ = layout + deep = repo / "pkg" / "a" / "b" / "c" + deep.mkdir(parents=True) + (deep / "leaf.py").write_text("def leaf():\n return 'leaf'\n", encoding="utf-8") + (repo / "pkg" / "a" / "mid.py").write_text("def mid():\n return 1\n", encoding="utf-8") + engine = RepoNeedleEngine.from_directory(str(repo)) + paths = sorted(d.path for d in engine.docs) + assert paths == ["pkg/a/b/c/leaf.py", "pkg/a/mid.py", "src/app.py", "src/real_module.py"] + assert engine.indexing_diagnostics.skipped_directories == 0 + assert engine.indexing_diagnostics.skipped_directory_paths == [] + + def test_directory_link_inside_repository_indexed_once_by_real_path(self, layout, caplog): + """Policy: a directory link resolving inside the repository is not traversed; its target's + files are indexed exactly once, under their real path. No warning is emitted.""" + repo, _ = layout + _dir_link(repo / "alias_dir", repo / "src") + with caplog.at_level("WARNING", logger="reponeedle.engine"): + engine = RepoNeedleEngine.from_directory(str(repo)) + paths = sorted(d.path for d in engine.docs) + assert paths == ["src/app.py", "src/real_module.py"] + diag = engine.indexing_diagnostics + assert diag.skipped_directory_paths == ["alias_dir"] + assert diag.skipped_directories_outside_repository == 0 + assert not [r for r in caplog.records if "directory link" in r.getMessage()] + + def test_directory_link_cycle_to_ancestor_is_bounded(self, layout): + repo, _ = layout + _dir_link(repo / "src" / "up", repo) # cycle: repo/src/up -> repo + engine = RepoNeedleEngine.from_directory(str(repo)) + paths = sorted(d.path for d in engine.docs) + assert paths == ["src/app.py", "src/real_module.py"] # each file once; no src/up/... copies + diag = engine.indexing_diagnostics + assert diag.skipped_directory_paths == ["src/up"] + assert diag.skipped_directories_outside_repository == 0 + assert diag.skipped_unreadable_files == 0 + + def test_directory_link_cycle_through_parent_does_not_escape(self, layout, tmp_path: Path): + """repo/src/out -> tmp_path (contains both the repository and the outside directory).""" + repo, _ = layout + _dir_link(repo / "src" / "out", tmp_path) + engine = RepoNeedleEngine.from_directory(str(repo)) + assert sorted(d.path for d in engine.docs) == ["src/app.py", "src/real_module.py"] + diag = engine.indexing_diagnostics + assert diag.skipped_directory_paths == ["src/out"] + assert diag.skipped_directories_outside_repository == 1 + assert diag.symlink_outside_repository_paths == [] # outside names never listed _assert_sentinel_unreachable(engine) + def test_mutual_directory_link_cycle(self, layout): + repo, _ = layout + (repo / "d1").mkdir() + (repo / "d2").mkdir() + (repo / "d1" / "one.py").write_text("ONE = 1\n", encoding="utf-8") + (repo / "d2" / "two.py").write_text("TWO = 2\n", encoding="utf-8") + _dir_link(repo / "d1" / "to_d2", repo / "d2") + _dir_link(repo / "d2" / "to_d1", repo / "d1") + engine = RepoNeedleEngine.from_directory(str(repo)) + paths = sorted(d.path for d in engine.docs) + assert paths == ["d1/one.py", "d2/two.py", "src/app.py", "src/real_module.py"] + assert engine.indexing_diagnostics.skipped_directory_paths == ["d1/to_d2", "d2/to_d1"] + + def test_broken_directory_link_is_safe(self, layout): + repo, _ = layout + gone = repo / "gone_target" + gone.mkdir() + _dir_link(repo / "dangling", gone) + gone.rmdir() + engine = RepoNeedleEngine.from_directory(str(repo)) + assert sorted(d.path for d in engine.docs) == ["src/app.py", "src/real_module.py"] + assert not any(p.startswith("dangling/") for p in engine.indexing_diagnostics.skipped_directory_paths) + + def test_link_detection_error_fails_closed(self, layout, monkeypatch): + """If a directory entry cannot be inspected (os.lstat raises), it is treated as a link: + not traversed, and recorded.""" + import reponeedle.engine as eng + repo, _ = layout + (repo / "uninspectable").mkdir() + (repo / "uninspectable" / "hidden.py").write_text("HIDDEN = 1\n", encoding="utf-8") + real_lstat = os.lstat + + def failing_lstat(path, *args, **kwargs): + if os.path.basename(os.fspath(path)) == "uninspectable": + raise PermissionError("simulated lstat failure") + return real_lstat(path, *args, **kwargs) + + monkeypatch.setattr(eng.os, "lstat", failing_lstat) + assert eng.is_directory_link(str(repo / "uninspectable")) is True + engine = RepoNeedleEngine.from_directory(str(repo)) + assert "uninspectable/hidden.py" not in {d.path for d in engine.docs} + assert engine.indexing_diagnostics.skipped_directory_paths == ["uninspectable"] + + def test_revisited_physical_directory_is_skipped(self, layout, monkeypatch): + """Cycle guard independent of link detection: a directory whose physical identity + (st_dev, st_ino) was already traversed is not entered again. SIMULATED: os.stat is patched + so that one directory reports the repository root's identity, as a bind mount of the root + would. No real bind mount is created (that needs root privileges).""" + import reponeedle.engine as eng + repo, _ = layout + (repo / "mount_like").mkdir() + (repo / "mount_like" / "dup.py").write_text("DUP = 1\n", encoding="utf-8") + real_stat = os.stat + root_stat = real_stat(str(repo)) + + def fake_stat(path, *args, **kwargs): + if os.path.basename(os.fspath(path)) == "mount_like": + return root_stat # same identity as the repository root, as a bind mount would have + return real_stat(path, *args, **kwargs) + + monkeypatch.setattr(eng.os, "stat", fake_stat) + engine = RepoNeedleEngine.from_directory(str(repo)) + assert "mount_like/dup.py" not in {d.path for d in engine.docs} + assert engine.indexing_diagnostics.skipped_directory_paths == ["mount_like"] + assert engine.indexing_diagnostics.skipped_directories_outside_repository == 0 + def test_symlink_inside_repository_still_works(self, layout): """Policy: links that resolve inside the repository are indexed under the link path.""" repo, _ = layout diff --git a/tests/test_windows_junctions.py b/tests/test_windows_junctions.py new file mode 100644 index 0000000..3dd72ed --- /dev/null +++ b/tests/test_windows_junctions.py @@ -0,0 +1,78 @@ +""" +Windows NTFS junction traversal. ``os.walk(followlinks=False)`` relies on ``os.path.islink``, +which is False for junctions, so junctions need explicit handling during indexing. + +Windows only: these tests skip elsewhere, so this module is deliberately NOT part of the strict +no-skip Linux CI job. Platform-neutral directory-link tests live in test_indexing_security.py. +""" + +import os +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="NTFS junctions are Windows-only") + +from reponeedle import RepoNeedleEngine # noqa: E402 +import reponeedle.engine as eng # noqa: E402 + +SENTINEL = "SENTINEL_JUNCTION_OUTSIDE_31c7" + + +def _junction(link: Path, target: Path): + import _winapi + _winapi.CreateJunction(str(target), str(link)) + + +@pytest.fixture +def layout(tmp_path: Path): + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.py").write_text(f"TOKEN = '{SENTINEL}'\n", encoding="utf-8") + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) + (repo / "src" / "app.py").write_text("def app():\n return 1\n", encoding="utf-8") + return repo, outside + + +def test_junction_is_detected_as_directory_link(layout): + repo, outside = layout + _junction(repo / "j", outside) + assert not os.path.islink(repo / "j") # why os.walk alone would descend into it + assert eng.is_directory_link(str(repo / "j")) + assert not eng.is_directory_link(str(repo / "src")) + + +def test_junction_detection_without_isjunction(layout, monkeypatch): + """Python 3.10/3.11 have no os.path.isjunction; detection reads the reparse tag via os.lstat + and must not depend on it.""" + repo, outside = layout + _junction(repo / "j", outside) + monkeypatch.delattr(os.path, "isjunction", raising=False) + assert eng.is_directory_link(str(repo / "j")) + assert not eng.is_directory_link(str(repo / "src")) + + +def test_junction_to_outside_is_not_traversed(layout): + repo, outside = layout + _junction(repo / "linked", outside) + engine = RepoNeedleEngine.from_directory(str(repo)) + diag = engine.indexing_diagnostics + assert [d.path for d in engine.docs] == ["src/app.py"] + assert diag.skipped_directory_paths == ["linked"] + assert diag.skipped_directories_outside_repository == 1 + assert diag.symlink_outside_repository_paths == [] + assert SENTINEL not in diag.model_dump_json() + assert SENTINEL not in engine.retrieve("TOKEN secret", budget_tokens=8000).rendered_context + + +def test_junction_cycle_to_repository_root_is_bounded(layout): + repo, _ = layout + _junction(repo / "src" / "up", repo) + engine = RepoNeedleEngine.from_directory(str(repo)) + assert [d.path for d in engine.docs] == ["src/app.py"] + diag = engine.indexing_diagnostics + assert diag.skipped_directory_paths == ["src/up"] + assert diag.skipped_directories_outside_repository == 0 + assert diag.skipped_unreadable_files == 0