diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6a8633c7..084e6cae 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,7 @@ -# Dev container matching CI (GitHub Actions ubuntu-22.04-8-cores + dtolnay/rust-toolchain@stable) -# Pin Rust to match CI's stable channel — this is the critical version. -# 1.96.0 = current stable on the CI runners; 1.94 fails the workspace build +# Dev container matching CI (.github/workflows/ci.yml — every job runs on +# ubuntu-24.04, or vars.BIG_RUNNER, with dtolnay/rust-toolchain@stable). +# CI resolves `stable` at run time; this image pins the concrete release so a +# container rebuild is reproducible. 1.94 fails the workspace build # (sysinfo 0.39.3 requires rustc >= 1.95). FROM rust:1.96.0-bookworm @@ -29,28 +30,41 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libdbus-1-3 \ && rm -rf /var/lib/apt/lists/* -# Python 3.12 (matches CI: actions/setup-python@v5 with python-version: "3.12") +# Python. CI pins 3.12 (actions/setup-python@v6 with python-version: "3.12"); +# this base image is Debian bookworm, whose `python3` is 3.11. That is the one +# knowingly unsynced version here — the interpreters are NOT identical, so a +# green container run is not proof of the CI matrix. RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ python3-pip \ python3-venv \ && rm -rf /var/lib/apt/lists/* -# Node 20 (matches CI: actions/setup-node@v4 with node-version: 20) -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ - && apt-get install -y --no-install-recommends nodejs=20.* \ +# Node 22 (matches CI: actions/setup-node@v6 with node-version 22.x in the +# `VS Code Extension` and `Shipwright Binary Gate` jobs — the ones this container +# reproduces through `make test` / `make build`. Only the website-build job, +# which this image does not run, pins Node 20.) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs=22.* \ && rm -rf /var/lib/apt/lists/* -# Rust components (matches CI: dtolnay/rust-toolchain@stable with components) -RUN rustup component add rustfmt clippy +# Rust components + targets (matches CI: dtolnay/rust-toolchain@stable with +# `rustfmt, clippy` in Lint, `llvm-tools-preview` in Rust Tests & Coverage — the +# component cargo-llvm-cov needs — and the wasm32-wasip2 target in Zed Extension) +RUN rustup component add rustfmt clippy llvm-tools-preview \ + && rustup target add wasm32-wasip2 # Rust cargo tools (pinned) RUN cargo install cargo-llvm-cov --version 0.8.4 --locked \ && cargo install cargo-audit --version 0.22.2 --locked # Python packages (pinned — ruff MUST match CI and the ruff_* crate tag in -# Cargo.toml: pip install debugpy ruff==0.15.17) -RUN pip install --break-system-packages debugpy==1.8.14 ruff==0.15.17 +# Cargo.toml). pytest drives the Neovim e2e harness (scripts/test-nvim.sh) and is +# pinned to CI's version; jinja2/markdown/tomlkit are the REAL python/typing +# conformance harness's own runtime deps, installed unpinned exactly as CI does. +RUN pip install --break-system-packages \ + debugpy==1.8.14 ruff==0.15.17 pytest==9.1.1 \ + jinja2 markdown tomlkit # GitHub CLI (for PR creation, issue management) RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ @@ -66,15 +80,50 @@ RUN curl -fsSL https://raw.githubusercontent.com/nektos/act/master/install.sh | # Claude Code CLI RUN npm install -g @anthropic-ai/claude-code +# Lua coverage for the Neovim suite. CI runs `luarocks install --local luacov` +# into the runner's HOME; installing it system-wide here puts it on the default +# LUA_PATH, so `make _test_nvim` finds it without an `eval "$(luarocks path)"`. +RUN apt-get update && apt-get install -y --no-install-recommends luarocks \ + && luarocks install luacov \ + && rm -rf /var/lib/apt/lists/* + # Match CI environment variables ENV CARGO_TERM_COLOR=always ENV RUSTFLAGS="-D warnings" -# Create a non-root user so Claude Code can use --dangerously-skip-permissions +# Create a non-root user so Claude Code can use --dangerously-skip-permissions. +# It is created BEFORE the Homebrew bootstrap below because Homebrew refuses to +# install as root and its installer expects passwordless sudo. RUN useradd -m -s /bin/bash -u 1000 dev \ && mkdir -p /workspace \ - && chown -R dev:dev /workspace /usr/local/cargo /usr/local/rustup + && chown -R dev:dev /workspace /usr/local/cargo /usr/local/rustup \ + && apt-get update \ + && apt-get install -y --no-install-recommends sudo git procps file build-essential \ + && echo 'dev ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/dev \ + && chmod 0440 /etc/sudoers.d/dev \ + && rm -rf /var/lib/apt/lists/* USER dev +# Homebrew — the package manager scripts/install-deslop.sh drives, and the one +# the CI Lint job uses to install the deslop gate. That script relies on the +# runner already having brew; a Debian image has to bootstrap it. +RUN NONINTERACTIVE=1 /bin/bash -c \ + "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +ENV PATH="/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${PATH}" + +# Neovim — `make test` runs the basilisk.nvim e2e suite (`make _test_nvim`) and +# scripts/audit.sh gates on it. basilisk.nvim requires 0.11+ (its health.lua +# hard-errors below that, and lua/basilisk/lsp.lua uses the 0.11-only +# vim.lsp.config/vim.lsp.enable); CI covers that floor and nightly via +# rhysd/action-setup-vim. Debian bookworm's nvim predates 0.11, so it comes from +# brew here — audit.sh re-verifies the floor before the suite runs either way. +RUN brew install neovim + +# deslop duplication gate — `make lint` runs it ([CI-DESLOP]). Installed with the +# repo's own script, byte-for-byte the step the CI Lint job runs, so the CLI here +# is the same latest release CI gates on. +COPY --chown=dev:dev scripts/install-deslop.sh /tmp/install-deslop.sh +RUN bash /tmp/install-deslop.sh && rm /tmp/install-deslop.sh + WORKDIR /workspace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 132a36f4..8a3fbd6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,8 +99,12 @@ jobs: website/*|benchmarks/status/*|crates/basilisk-checker/src/rules/*) website=true ;; # The conformance score + graded python/typing commit are stamped # into these by scripts/gen_conformance_reference.py — editing - # them re-runs the website job's stamp drift guard. - README.md|README.zh.md|docs/specs/CHECKER-ARCHITECTURE-SPEC.md) website=true ;; + # them re-runs the website job's stamp drift guard. The published + # READMEs are generated from docs/readme/ ([README]), so touching + # a source OR a generated copy must re-run that guard too. + docs/readme/*|docs/specs/CHECKER-ARCHITECTURE-SPEC.md) website=true ;; + README.md|README.zh.md|README-pypi.md) website=true ;; + vscode-extension/README.md|vscode-extension/README.zh.md) website=true ;; esac # Route each changed path to the NARROWEST code scope that needs it. # Pure docs / static site / benchmark tooling / CI YAML never reach a @@ -174,11 +178,14 @@ jobs: diff -u website/src/_data/rules.json /tmp/rules.json \ || { echo "::error::rules.json is stale — run: python3 scripts/gen_rules_reference.py --data"; exit 1; } - # The README/spec quote the live score + graded python/typing commit, - # stamped by scripts/gen_conformance_reference.py on every scorer run; - # fail if the committed text drifted from conformance_report.json so a - # quoted commit can never go stale ([CHKARCH-CONFORMANCE]). - - name: Check stamped conformance references are in sync with the report + # The README source/spec quote the live score + graded python/typing + # commit, stamped by scripts/gen_conformance_reference.py on every scorer + # run; fail if the committed text drifted from conformance_report.json so + # a quoted commit can never go stale ([CHKARCH-CONFORMANCE]). The same + # command then verifies that every published README still renders from + # docs/readme/ unchanged ([README-DRIFT]) — GitHub, the VSIX (Marketplace + # AND Open VSX), and PyPI cannot drift apart or be edited in place. + - name: Check stamped conformance references and generated READMEs run: python3 scripts/gen_conformance_reference.py --check - name: Build site @@ -548,9 +555,13 @@ jobs: with: python-version: "3.12" - - name: Install pytest and luarocks + - name: Install pytest, debugpy and luarocks run: | - pip install pytest==9.1.1 + # debugpy is a real dependency of this job, not a nicety: the + # tests/dap specs drive basilisk's debug adapter end to end, and + # without it every one of them fails with "debugpy not found". + # Pinned to the same version as test-vscode and the devcontainer. + pip install pytest==9.1.1 debugpy==1.8.14 sudo apt-get install -y luarocks luarocks install --local luacov diff --git a/CLAUDE.md b/CLAUDE.md index 7b68eb59..9b818e7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,7 @@ Strict-by-default Python type checker and comprehensive LSP built in **Rust**. O - **Parser**: `ruff_python_parser` (MIT, same as Ruff) - **Incremental**: Salsa framework — sub-10ms incremental checks - **Formatting**: `ruff_python_formatter` crate embedded in-process ([LSPFMT-ENGINE]); import hygiene reimplemented natively on the Ruff AST ([LSPFMT-IMPORTS]). The `ruff` CLI is NOT a runtime dependency — never spawn it. -- **Parallelism**: Rayon (work-stealing, file-level) +- **Concurrency**: Tokio in the LSP server (request multiplexing + `spawn_blocking`); analysis itself is single-threaded on one dedicated large-stack thread ([LSPARCH-ARCH-STACK]) - **No Pyright/mypy/Node.js** — zero TypeScript or Python runtime ## Migration to `lspkit` diff --git a/Cargo.lock b/Cargo.lock index 6ae3e1b1..e182218d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,6 +260,7 @@ dependencies = [ "basilisk-resolver", "basilisk-stubs", "basilisk-test-utils", + "basilisk-typeshed-fetch", "basilisk-uv", "clap", "colored", @@ -325,6 +326,7 @@ dependencies = [ "basilisk-resolver", "basilisk-stubs", "basilisk-test-utils", + "basilisk-typeshed-fetch", "basilisk-uv", "dashmap 6.2.1", "futures-util", @@ -417,7 +419,6 @@ dependencies = [ "tempfile", "thiserror", "tracing", - "ureq", "zip", ] @@ -443,6 +444,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "basilisk-typeshed-fetch" +version = "0.0.0-PLACEHOLDER" +dependencies = [ + "basilisk-stubs", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tracing", + "ureq", + "zip", +] + [[package]] name = "basilisk-uv" version = "0.0.0-PLACEHOLDER" diff --git a/Cargo.toml b/Cargo.toml index 484c4dc7..e0b767ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/basilisk-checker", "crates/basilisk-cli", "crates/basilisk-stubs", + "crates/basilisk-typeshed-fetch", "crates/basilisk-config", "crates/basilisk-lsp", "crates/basilisk-mojo", @@ -73,6 +74,7 @@ basilisk-checker = { path = "crates/basilisk-checker" } basilisk-lsp = { path = "crates/basilisk-lsp" } basilisk-compiler = { path = "crates/basilisk-compiler" } basilisk-stubs = { path = "crates/basilisk-stubs" } +basilisk-typeshed-fetch = { path = "crates/basilisk-typeshed-fetch" } basilisk-config = { path = "crates/basilisk-config" } basilisk-common = { path = "crates/basilisk-common" } basilisk-buildinfo = { path = "crates/basilisk-buildinfo" } diff --git a/Makefile b/Makefile index ffd95c82..e14b5afe 100644 --- a/Makefile +++ b/Makefile @@ -94,7 +94,7 @@ test: _audit echo -e '\n\033[0;32m✓ All tests passed.\033[0m' ## lint: Run all linters/analyzers (read-only). Does NOT format. -lint: _lint_rust _lint_vsix _lint_deslop +lint: _lint_rust _lint_vsix _lint_deslop _lint_docs ## fmt: Format all code in-place fmt: _fmt_rust _fmt_python _fmt_vsix @@ -396,6 +396,7 @@ _lint_rust: cargo check --workspace --all-targets && \ cargo clippy --workspace --all-targets -- -D warnings && \ cargo audit && \ + bash scripts/check-dependency-shape.sh && \ echo -e '\033[0;32m✓ Rust lint passed\033[0m' _lint_vsix: @@ -411,6 +412,19 @@ _lint_deslop: deslop . && \ echo -e '\033[0;32m✓ Deslop duplication gate passed\033[0m' +# Generated-documentation drift gates. The published READMEs (GitHub, the VSIX +# on both Marketplace and Open VSX, PyPI) are rendered from docs/readme/ +# ([README]), and the diagnostic reference data is generated from the checker +# rule sources ([WEBSITE-ERROR-PAGES-DRIFT]) — editing either output by hand, +# or editing a source without regenerating, fails here as it does in CI. +_lint_docs: + @echo -e '\033[1m\033[0;36m▶ Checking generated documentation\033[0m' && \ + python3 scripts/gen_readmes.py --check && \ + python3 scripts/gen_rules_reference.py --data /tmp/basilisk-rules.json && \ + diff -u website/src/_data/rules.json /tmp/basilisk-rules.json > /dev/null || \ + { echo 'rules.json is stale — run: python3 scripts/gen_rules_reference.py --data'; exit 1; } && \ + echo -e '\033[0;32m✓ Generated documentation is in sync\033[0m' + _fmt_rust: @echo -e '\033[1m\033[0;36m▶ Formatting Rust\033[0m' && \ cargo fmt --all && \ diff --git a/README-pypi.md b/README-pypi.md index 3f86a271..fb8062b6 100644 --- a/README-pypi.md +++ b/README-pypi.md @@ -1,67 +1,175 @@ -# Basilisk + +

+ Basilisk +

+ +

Basilisk

+ +

English · 简体中文

+ +

+ The only Python type checker scoring 100% on the official python/typing conformance suite — and the fastest we’ve measured.
+ Complete open-source Python dev environment in Rust: type checker, language server, debugger, profiler, plus VS Code, Cursor, Zed & Neovim extensions. Strict by default. + Weighing up the best Python type checker for your codebase? Start with the scoreboard. +

+ +> **You are reading the `basilisk-python` wheel listing** — the Basilisk CLI packaged for `pip`/`uv`. The distribution is named `basilisk-python` because `basilisk` was taken on PyPI; the installed command is still `basilisk`. + +

+ Website  •  + Install  •  + Quick Start  •  + Rules  •  + Refactoring  •  + Compare  •  + GitHub +

+ +

+ 100.0% PEP conformance141 of 141 tests in the official + python/typing + conformance suite (commit 6ef9f77), scored on the wheel-installed CLI in its default config by the real upstream harness. + We target python/typing@main and ratchet the score up only. +

+ +## The only 100% checker — and the fastest according to our benchmarks + +Basilisk is the **only** Python type checker with a perfect score on the official +[`python/typing` conformance suite](https://github.com/python/typing/blob/main/conformance/results/results.html): +**100.0%** (141/141 files, 970 required errors caught, 0 false positives), +measured by the real upstream harness on the wheel-installed CLI in its default config. + +

+ Basilisk in action — type checking, diagnostics, and refactoring in the editor +

+ +And it is the **fastest checker we’ve measured** — median cold full-file check, from scratch: + +| Type checker | Median cold check | +| --- | --- | +| ⚡ **Basilisk** | **10 ms** | +| zuban | 28 ms | +| ty | 40 ms | +| Pyrefly | 110 ms | +| Pyright | 573 ms | +| mypy | 574 ms | + +Median cold full-file check across 26 single-construct typing-spec stress fixtures on an Apple M4 Max — lower is better. Basilisk’s warm re-check drops to ~4 ms. Every figure is produced by [`hyperfine`](https://github.com/sharkdp/hyperfine) and committed per machine, so nothing here is hand-typed. **Clone the repo, run `make bench` on your own hardware, and send us the CSV — independent audits are welcome.** [Full benchmarks & methodology →](https://www.basilisk-python.dev/docs/benchmarks/) + +## Everything in one extension + +One extension replaces Pylance and gives you the whole workflow — no Node.js, no Python runtime, no pip, no npm. A single bundled Rust binary drives it all: + +- **Strict-by-default diagnostics** — inline as you type, incremental analysis powered by Salsa (the rust-analyzer engine) +- **Autocomplete, hover, go-to-definition, find references, rename** +- **Refactoring code actions** — extract, inline, move symbol, organize imports +- **Integrated debugging** — F5 to debug via bundled debugpy; no separate extension +- **Integrated profiling** — CPU heat map, flame graph, and a memory dashboard with leak detection +- **Activity panel** — module tree with per-module type-health coverage, plus feature toggles +- **Inlay hints** and **Ruff** formatting/import-organization, built in +- **Standard-library types from [typeshed](https://github.com/python/typeshed)** — a complete `stdlib/` snapshot is compiled into the binary, so hover and diagnostics work offline with no configuration + +Every diagnostic teaches: rustc-style output with a `help`, a `note`, and a link to a per-rule explainer, so a red squiggle always tells you *why*. Basilisk **starts strict** and stays strict — the unconfigured default enables the complete typing-spec rule set, and strictness is dialled per rule, never by a mode. -**Basilisk is the only Python type checker scoring 100%** on the -[official `python/typing` conformance suite](https://github.com/python/typing/blob/main/conformance/results/results.html) — -and the fastest we've measured. A complete, open-source Python development -environment in Rust: type checker, language server, debugger, profiler, plus -VS Code, Cursor, Zed & Neovim extensions. Strict by default. +## Install -- Website: -- Conformance: -- Source: +**Editor extension** — install *Basilisk* from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) or [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk) (Cursor, Windsurf, and other forks read Open VSX). The Basilisk binary is bundled for macOS (Apple Silicon), Linux (x86_64, aarch64), and Windows (x86_64, aarch64) — nothing else to install. Zed and Neovim 0.10+ extensions are available too. -This package (`basilisk-python`) bundles the native `basilisk` binary as a -Python wheel so it can be installed with `pip`/`uv`. It is built from the **same -source, at the same version**, as the binaries distributed via GitHub Releases, -Homebrew and Scoop — every channel is stamped from the Cargo workspace version -by `scripts/stamp-version.sh` in one release run. The wheel is compiled by its -own `maturin --release` job, so the file itself is not byte-identical to the -release archive's binary. +**CLI** — on [PyPI as `basilisk-python`](https://pypi.org/project/basilisk-python/); the installed command is `basilisk`: -> The distribution is named `basilisk-python` because the name `basilisk` was -> already taken on PyPI. The installed command is still `basilisk`. +```sh +uv tool install basilisk-python # or: pipx install basilisk-python, pip install basilisk-python +``` -## Install +Also via Homebrew (`brew install Nimblesite/tap/basilisk`), Scoop (`scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket && scoop install basilisk`), and [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases). Every channel ships the same single Rust CLI, built from this repository at the same version, with no runtime dependencies. Point `basilisk.executablePath` at your own build to have the extension use it. Full options: [install guide](https://www.basilisk-python.dev/docs/installation/). -```bash -pip install basilisk-python -# or -uv tool install basilisk-python -``` +## Try it -## Use +The [`examples/`](https://github.com/Nimblesite/Basilisk/blob/main/examples/) folder has ready-to-go Python files: -```bash -basilisk check path/to/your_code.py -basilisk --version +```sh +basilisk check examples/bad.py # 8 typing-spec errors — always on, no config needed +basilisk analyze examples/bad.py # the opt-in strictness warnings on the same file +basilisk analyze examples/good.py # clean, even at full strictness +basilisk check examples/mixed.py # one real type error +basilisk check examples/ # the whole folder at once ``` -Machine-readable output for tooling: +Machine-readable output for CI and tooling: -```bash +```sh basilisk check path/to/your_code.py --output json --color never ``` -## Standard-library types +The two commands read one rule universe split by provenance ([`CHKARCH-COMMANDS`](https://github.com/Nimblesite/Basilisk/blob/main/docs/specs/CHECKER-ARCHITECTURE-SPEC.md)): `check` reports +the `pep`-tagged typing-spec rules and nothing else — that set is always on, and +while a config table may grade one of them down to `warning`/`info`, none may +switch it off. `analyze` reports the non-`pep` house rules, which stay silent +until a table selects them. Only `analyze` emits `BSK-` diagnostics. + +## Standard-library types, always offline + +Basilisk resolves the standard library from [typeshed](https://github.com/python/typeshed), +and checking **never downloads anything**. Out of the box it uses the complete +typeshed `stdlib/` snapshot compiled into the binary, reporting the source as +unpinned — so stdlib types work on a plane, behind a firewall, or in an +air-gapped CI runner, with no configuration. + +Pin an exact commit with `typeshed-commit = "<40-char sha>"` under +`[tool.basilisk]`. A pin does exactly one thing: it verifies, offline, that the +typeshed tree in the local store hashes to that commit. If the commit is not on +this machine the run fails hard with `NO SOURCE` rather than substituting +another source — bring it down first with `basilisk typeshed download` (with no +`--commit` it downloads the latest and writes the pin for you), or use the +editor's **Download latest** button. Alternatively, point `typeshed-path` at +your own typeshed tree. Full options: +[configuration guide](https://www.basilisk-python.dev/docs/configuration/). + +## Development + +```sh +cargo build # build all crates +cargo test # run all tests +cargo clippy # lint (zero warnings policy) +cargo fmt # format +``` + +Rust 1.87+ required. -Standard-library types come from [typeshed](https://github.com/python/typeshed). -By default Basilisk verifies `python/typeshed@main` over HTTPS once per run and -caches the gated archive for 24 hours; with no network it falls back to the -complete typeshed `stdlib/` snapshot compiled into the binary, so offline and -air-gapped runs still get stdlib types. Pin an exact commit with -`typeshed-commit` under `[tool.basilisk]` in `pyproject.toml`, or skip the cache -for a single run with `--no-typeshed-cache`. +## Contributing + +Basilisk is built by a human + AI partnership, with the work split on purpose. See +[CONTRIBUTING.md](https://github.com/Nimblesite/Basilisk/blob/main/CONTRIBUTING.md) — **For Humans** (testing, code-quality review, +conformance/security audits, IDE feature parity, sharpening the AI instructions) and +**For AI** (the technical execution, under the standing rules in [CLAUDE.md](https://github.com/Nimblesite/Basilisk/blob/main/CLAUDE.md)). ## Acknowledgments -The bundled binary is built on [Ruff](https://github.com/astral-sh/ruff) by -[Astral](https://astral.sh/) (MIT) and [typeshed](https://github.com/python/typeshed) -(Apache-2.0, with MIT-licensed parts), among other open-source projects. The -wheel carries the complete locked notices and license texts in its -`.dist-info/licenses/` directory. +Basilisk builds on the open-source community — with thanks to: + +- **[Astral](https://astral.sh/)** — [Ruff](https://github.com/astral-sh/ruff), whose parser, AST, and formatter crates Basilisk embeds (MIT). The foundation we rely on most. +- **[typeshed](https://github.com/python/typeshed)** — standard-library type stubs (Apache-2.0, with MIT-licensed parts). +- **[Salsa](https://github.com/salsa-rs/salsa)** — incremental query engine. +- **[Rayon](https://github.com/rayon-rs/rayon)** — data parallelism. +- **[tower-lsp](https://github.com/ebkalderon/tower-lsp)** — LSP scaffolding. +- **[debugpy](https://github.com/microsoft/debugpy)** — debug adapter (bundled in the VS Code extension). +- The [`python/typing`](https://github.com/python/typing) conformance suite. + +Full component list, selected licenses, and required notices: [NOTICES](https://github.com/Nimblesite/Basilisk/blob/main/NOTICES) +and [RUST-DEPENDENCY-LICENSES](https://github.com/Nimblesite/Basilisk/blob/main/RUST-DEPENDENCY-LICENSES). Each published +artifact carries its own copies: the VSIX ships Rust notices in +`RUST-DEPENDENCY-LICENSES`, npm notices in `VSCODE-DEPENDENCY-LICENSES`, and +debugpy's license and `ThirdPartyNotices.txt` inside `bundled/debugpy`; the +wheel carries the complete locked notices in its `.dist-info/licenses/` +directory. + +--- ## License -Basilisk source code is MIT licensed. This binary wheel also contains -third-party components under the composite `License-Expression` and the exact -license files shipped in `.dist-info/licenses/`. +Basilisk source code is MIT licensed. Binary distributions also contain +third-party components under the licenses shipped beside each artifact. + +Built by [NIMBLESITE PTY LTD](https://www.nimblesite.co). diff --git a/README.md b/README.md index 17bcd696..3a1b426b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +

Basilisk

@@ -9,15 +12,19 @@

The only Python type checker scoring 100% on the official python/typing conformance suite — and the fastest we’ve measured.
Complete open-source Python dev environment in Rust: type checker, language server, debugger, profiler, plus VS Code, Cursor, Zed & Neovim extensions. Strict by default. + Weighing up the best Python type checker for your codebase? Start with the scoreboard.

+> **You are reading the Basilisk source repository** — the checker, language server, editor extensions, and website all live here. +

Website  •  Install  •  Quick Start  •  Rules  •  Refactoring  •  - Compare + Compare  •  + GitHub

@@ -51,24 +58,36 @@ And it is the **fastest checker we’ve measured** — median cold full- Median cold full-file check across 26 single-construct typing-spec stress fixtures on an Apple M4 Max — lower is better. Basilisk’s warm re-check drops to ~4 ms. Every figure is produced by [`hyperfine`](https://github.com/sharkdp/hyperfine) and committed per machine, so nothing here is hand-typed. **Clone the repo, run `make bench` on your own hardware, and send us the CSV — independent audits are welcome.** [Full benchmarks & methodology →](https://www.basilisk-python.dev/docs/benchmarks/) +## Everything in one extension + +One extension replaces Pylance and gives you the whole workflow — no Node.js, no Python runtime, no pip, no npm. A single bundled Rust binary drives it all: + +- **Strict-by-default diagnostics** — inline as you type, incremental analysis powered by Salsa (the rust-analyzer engine) +- **Autocomplete, hover, go-to-definition, find references, rename** +- **Refactoring code actions** — extract, inline, move symbol, organize imports +- **Integrated debugging** — F5 to debug via bundled debugpy; no separate extension +- **Integrated profiling** — CPU heat map, flame graph, and a memory dashboard with leak detection +- **Activity panel** — module tree with per-module type-health coverage, plus feature toggles +- **Inlay hints** and **Ruff** formatting/import-organization, built in +- **Standard-library types from [typeshed](https://github.com/python/typeshed)** — a complete `stdlib/` snapshot is compiled into the binary, so hover and diagnostics work offline with no configuration +Every diagnostic teaches: rustc-style output with a `help`, a `note`, and a link to a per-rule explainer, so a red squiggle always tells you *why*. Basilisk **starts strict** and stays strict — the unconfigured default enables the complete typing-spec rule set, and strictness is dialled per rule, never by a mode. ## Install -The CLI is on [PyPI as `basilisk-python`](https://pypi.org/project/basilisk-python/) — install it as a standalone tool; the installed command is `basilisk`: +**Editor extension** — install *Basilisk* from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) or [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk) (Cursor, Windsurf, and other forks read Open VSX). The Basilisk binary is bundled for macOS (Apple Silicon), Linux (x86_64, aarch64), and Windows (x86_64, aarch64) — nothing else to install. Zed and Neovim 0.10+ extensions are available too. + +**CLI** — on [PyPI as `basilisk-python`](https://pypi.org/project/basilisk-python/); the installed command is `basilisk`: ```sh -uv tool install basilisk-python # or: pipx install basilisk-python +uv tool install basilisk-python # or: pipx install basilisk-python, pip install basilisk-python ``` -Also available via Homebrew (`brew tap Nimblesite/tap && brew install basilisk`), Scoop, and -[GitHub Releases](https://github.com/Nimblesite/Basilisk/releases) — every channel ships the same -single Rust CLI, built from this repository at the same version, with no runtime dependencies. -Full options: [install guide](https://www.basilisk-python.dev/docs/install-cli/). +Also via Homebrew (`brew install Nimblesite/tap/basilisk`), Scoop (`scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket && scoop install basilisk`), and [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases). Every channel ships the same single Rust CLI, built from this repository at the same version, with no runtime dependencies. Point `basilisk.executablePath` at your own build to have the extension use it. Full options: [install guide](https://www.basilisk-python.dev/docs/installation/). ## Try it -The `examples/` folder has ready-to-go Python files: +The [`examples/`](examples/) folder has ready-to-go Python files: ```sh basilisk check examples/bad.py # 8 typing-spec errors — always on, no config needed @@ -78,39 +97,36 @@ basilisk check examples/mixed.py # one real type error basilisk check examples/ # the whole folder at once ``` +Machine-readable output for CI and tooling: + +```sh +basilisk check path/to/your_code.py --output json --color never +``` + The two commands read one rule universe split by provenance ([`CHKARCH-COMMANDS`](docs/specs/CHECKER-ARCHITECTURE-SPEC.md)): `check` reports the `pep`-tagged typing-spec rules and nothing else — that set is always on, and while a config table may grade one of them down to `warning`/`info`, none may switch it off. `analyze` reports the non-`pep` house rules, which stay silent until a table selects them. Only `analyze` emits `BSK-` diagnostics. -## Standard-library types, online or offline +## Standard-library types, always offline -Basilisk resolves the standard library from [typeshed](https://github.com/python/typeshed). -By default it verifies `python/typeshed@main` over HTTPS once per run, gates and -caches the archive for up to 24 hours, and reports the source as unpinned. With no -network — or on any download failure — it falls back to the complete typeshed -`stdlib/` snapshot compiled into the binary, so stdlib types still work offline. +Basilisk resolves the standard library from [typeshed](https://github.com/python/typeshed), +and checking **never downloads anything**. Out of the box it uses the complete +typeshed `stdlib/` snapshot compiled into the binary, reporting the source as +unpinned — so stdlib types work on a plane, behind a firewall, or in an +air-gapped CI runner, with no configuration. Pin an exact commit with `typeshed-commit = "<40-char sha>"` under -`[tool.basilisk]`; its cache is re-hashed on every reuse and remains until eviction -or caching is disabled. The pin fails closed rather than substituting another -commit. Alternatively, point `typeshed-path` at your own typeshed tree. Full options: +`[tool.basilisk]`. A pin does exactly one thing: it verifies, offline, that the +typeshed tree in the local store hashes to that commit. If the commit is not on +this machine the run fails hard with `NO SOURCE` rather than substituting +another source — bring it down first with `basilisk typeshed download` (with no +`--commit` it downloads the latest and writes the pin for you), or use the +editor's **Download latest** button. Alternatively, point `typeshed-path` at +your own typeshed tree. Full options: [configuration guide](https://www.basilisk-python.dev/docs/configuration/). - - -## Editors - -One extension, the whole workflow: strict-by-default diagnostics, autocomplete, hover, go-to-definition, refactoring code actions, debugging, and profiling. No Node.js or Python runtime — a single Rust binary drives it all. - -- **VS Code, Cursor & Windsurf** — install from [Open VSX](https://open-vsx.org/) -- **Zed** • **Neovim 0.10+** - -Every diagnostic teaches: rustc-style output with a `help`, a `note`, and a link to a per-rule explainer. See the [full diagnostic reference](https://www.basilisk-python.dev/docs/rules/) and the [install guide](https://www.basilisk-python.dev/docs/installation/). - - - ## Development ```sh @@ -129,7 +145,6 @@ Basilisk is built by a human + AI partnership, with the work split on purpose. S conformance/security audits, IDE feature parity, sharpening the AI instructions) and **For AI** (the technical execution, under the standing rules in [CLAUDE.md](CLAUDE.md)). - ## Acknowledgments Basilisk builds on the open-source community — with thanks to: @@ -143,9 +158,12 @@ Basilisk builds on the open-source community — with thanks to: - The [`python/typing`](https://github.com/python/typing) conformance suite. Full component list, selected licenses, and required notices: [NOTICES](NOTICES) -and [RUST-DEPENDENCY-LICENSES](RUST-DEPENDENCY-LICENSES). - - +and [RUST-DEPENDENCY-LICENSES](RUST-DEPENDENCY-LICENSES). Each published +artifact carries its own copies: the VSIX ships Rust notices in +`RUST-DEPENDENCY-LICENSES`, npm notices in `VSCODE-DEPENDENCY-LICENSES`, and +debugpy's license and `ThirdPartyNotices.txt` inside `bundled/debugpy`; the +wheel carries the complete locked notices in its `.dist-info/licenses/` +directory. --- diff --git a/README.zh.md b/README.zh.md index 31821243..62a1c830 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,3 +1,6 @@ +

Basilisk

@@ -6,42 +9,46 @@

English · 简体中文

-> 📝 本文档由机器翻译生成,欢迎母语者校对改进。 -

- 唯一在官方 python/typing 符合性套件中取得 100% 满分的 Python 类型检查器 —— 也是我们测过的最快的。
- 使用 Rust 构建的完整开源 Python 开发环境:类型检查器、语言服务器、调试器与性能分析器,并提供 VS Code、Cursor、Zed 与 Neovim 扩展。默认严格。 + 唯一在官方 python/typing 一致性测试套件上取得 100% 的 Python 类型检查器 —— 也是我们测得最快的。
+ 用 Rust 打造的完整开源 Python 开发环境:类型检查器、语言服务器、调试器、性能分析器,以及 VS Code、Cursor、Zed 与 Neovim 扩展。默认严格。

+> **你正在阅读 Basilisk 的源码仓库** —— 检查器、语言服务器、编辑器扩展与网站都在这里。 +

- 官网  •  + 网站  •  安装  •  - 快速开始  •  + 快速上手  •  规则  •  重构  •  - 对比 + 对比  •  + GitHub

- PEP 符合性 100.0% — 官方 + PEP 一致性 100.0% — 官方 python/typing - 符合性套件 141 个测试中通过 141 个(提交 6ef9f77),由真实的上游评分工具在默认配置下对通过 wheel 安装的 CLI 评分。 - 我们对标 python/typing@main,得分只升不降。 + 一致性套件(提交 6ef9f77141 项测试中通过 141 项, + 由真实的上游评分器在默认配置下对 wheel 安装的 CLI 评出。 + 我们以 python/typing@main 为目标,且分数只升不降。

-## 唯一满分 —— 且最快 +## 唯一 100% 的检查器 — 按我们的基准测试也是最快的 Basilisk 是**唯一**在官方 -[`python/typing` 符合性套件](https://github.com/python/typing/blob/main/conformance/results/results.html) -上取得满分的 Python 类型检查器:**100.0%**(141/141 个文件,捕获 970 个必需错误,0 个误报),由真实的上游评分工具在默认配置下对通过 wheel 安装的 CLI 评分。 +[`python/typing` 一致性套件](https://github.com/python/typing/blob/main/conformance/results/results.html) +上取得满分的 Python 类型检查器:**100.0%** +(141/141 个文件,捕获 970 处必需错误,0 个误报), +由真实的上游评分器在默认配置下对 wheel 安装的 CLI 测得。

- Basilisk 实战 —— 在编辑器中进行类型检查、诊断与重构 + Basilisk 实战 —— 编辑器中的类型检查、诊断与重构

-它也是**我们测试过最快的检查器** —— 从零冷启动的全文件检查中位数: +它也是**我们测得最快的检查器** — 从零开始的整文件冷检查中位数: -| 类型检查器 | 冷启动检查中位数 | +| 类型检查器 | 冷检查中位数 | | --- | --- | | ⚡ **Basilisk** | **10 ms** | | zuban | 28 ms | @@ -50,56 +57,71 @@ Basilisk 是**唯一**在官方 | Pyright | 573 ms | | mypy | 574 ms | -在 Apple M4 Max 上,跨 26 个单一类型构造压力测试样本的冷启动全文件检查中位数 —— 数值越低越好。Basilisk 的热重检查可降至约 4 ms。每个数字均由 [`hyperfine`](https://github.com/sharkdp/hyperfine) 生成并按机器提交,没有一个是手写的。**克隆仓库,在你自己的硬件上运行 `make bench`,并把 CSV 发给我们 —— 欢迎社区独立复核。** [完整基准测试与方法论(英文)→](https://www.basilisk-python.dev/docs/benchmarks/) +在 Apple M4 Max 上对 26 个单一构造的类型规范压力用例测得的整文件冷检查中位数 — 越低越好。Basilisk 的热重检查可降至约 4 ms。每个数字都由 [`hyperfine`](https://github.com/sharkdp/hyperfine) 产生并按机器提交,没有一个是手写的。**克隆仓库,在你自己的硬件上运行 `make bench`,并把 CSV 发给我们 — 欢迎独立复核。** [完整基准与方法论 →](https://www.basilisk-python.dev/zh/docs/benchmarks/) + +## 一个扩展,覆盖全部 + +一个扩展即可取代 Pylance 并提供完整工作流 —— 无需 Node.js、无需 Python 运行时、无需 pip、无需 npm。一切由单一捆绑的 Rust 二进制文件驱动: + +- **默认严格的诊断** —— 随输入实时呈现,由 Salsa(rust-analyzer 的引擎)提供增量分析 +- **自动补全、悬停信息、跳转到定义、查找引用、重命名** +- **重构代码操作** —— 提取、内联、移动符号、整理导入 +- **集成调试** —— 按 F5 即可通过捆绑的 debugpy 调试;无需额外扩展 +- **集成性能分析** —— CPU 热力图、火焰图,以及带泄漏检测的内存面板 +- **活动面板** —— 模块树与逐模块的类型健康度覆盖率,并可切换功能开关 +- 内置 **Inlay hints** 与 **Ruff** 格式化/导入整理 +- **来自 [typeshed](https://github.com/python/typeshed) 的标准库类型** —— 完整的 `stdlib/` 快照已编译进二进制文件,因此悬停与诊断在离线且零配置的情况下依然可用 + +每条诊断都有教育意义:rustc 风格的输出,附带 `help`、`note` 以及指向每条规则详解页的链接,因此一条红色波浪线总能告诉你*为什么*。Basilisk **一开始就严格**并始终严格 —— 未配置的默认值即启用完整的类型规范规则集,严格程度按规则微调,而不是靠模式切换。 ## 安装 -CLI 已发布到 [PyPI,包名为 `basilisk-python`](https://pypi.org/project/basilisk-python/)——请将其作为独立工具安装,安装后的命令是 `basilisk`: +**编辑器扩展** —— 从 [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) 或 [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk) 安装 *Basilisk*(Cursor、Windsurf 等分支读取 Open VSX)。Basilisk 二进制文件已为 macOS(Apple Silicon)、Linux(x86_64、aarch64)与 Windows(x86_64、aarch64)捆绑 —— 无需再安装其他东西。Zed 与 Neovim 0.10+ 的扩展同样可用。 + +**命令行工具** —— 在 [PyPI 上名为 `basilisk-python`](https://pypi.org/project/basilisk-python/);安装后的命令是 `basilisk`: ```sh -uv tool install basilisk-python # 或:pipx install basilisk-python +uv tool install basilisk-python # 或:pipx install basilisk-python、pip install basilisk-python ``` -也可通过 Homebrew(`brew tap Nimblesite/tap && brew install basilisk`)、Scoop 以及 -[GitHub Releases](https://github.com/Nimblesite/Basilisk/releases) 安装——所有渠道分发的都是同一个 -Rust CLI,由本仓库同一版本构建,且没有任何运行时依赖。完整选项参见[安装指南](https://www.basilisk-python.dev/zh/docs/installation/)。 +也可通过 Homebrew(`brew install Nimblesite/tap/basilisk`)、Scoop(`scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket && scoop install basilisk`)与 [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases) 获取。每个渠道都发布同一个 Rust 命令行工具,由本仓库在同一版本构建,且没有运行时依赖。把 `basilisk.executablePath` 指向你自己的构建,扩展就会使用它。完整选项:[安装指南](https://www.basilisk-python.dev/zh/docs/installation/)。 -## 试用 +## 试一试 -`examples/` 文件夹中提供了可直接运行的 Python 文件: +[`examples/`](examples/) 目录中有可直接运行的 Python 文件: ```sh -basilisk check examples/bad.py # 8 个类型规范错误——始终启用,无需任何配置 -basilisk analyze examples/bad.py # 同一文件上可选启用的严格性警告 -basilisk analyze examples/good.py # 干净——即使在完全严格模式下 -basilisk check examples/mixed.py # 一个真实的类型错误 -basilisk check examples/ # 一次检查整个文件夹 +basilisk check examples/bad.py # 8 处类型规范错误 —— 始终启用,无需配置 +basilisk analyze examples/bad.py # 同一文件上可选的严格性警告 +basilisk analyze examples/good.py # 即使在完全严格下也是干净的 +basilisk check examples/mixed.py # 一处真实的类型错误 +basilisk check examples/ # 一次检查整个目录 ``` -这两个命令读取的是同一套规则,只是按来源做了划分([`CHKARCH-COMMANDS`](docs/specs/CHECKER-ARCHITECTURE-SPEC.md)):`check` 只报告带 -`pep` 标签的类型规范规则——这组规则始终启用,配置表可以把其中某条降级为 -`warning`/`info`,但任何表都无法将其关闭;`analyze` 报告不带 `pep` 标签的自定 -规则,它们在被配置表选用之前始终保持沉默。只有 `analyze` 会输出 `BSK-` 诊断。 - -## 标准库类型:在线与离线皆可 - -Basilisk 从 [typeshed](https://github.com/python/typeshed) 解析标准库类型。默认情况下, -它每次运行通过 HTTPS 校验一次 `python/typeshed@main`,对归档执行安全校验并缓存 24 小时, -同时将来源标记为未固定(unpinned)。在没有网络或下载失败时,它会回退到编译进二进制文件中的 -完整 typeshed `stdlib/` 快照,因此标准库类型在离线状态下依然可用。 +供 CI 与工具使用的机器可读输出: -在 `[tool.basilisk]` 中使用 `typeshed-commit = "<40 位 sha>"` 固定到某个确切提交 -(该配置失败即报错,绝不替换为其他提交),或用 `typeshed-path` 指向你自己的 typeshed -目录树。完整选项参见[配置指南](https://www.basilisk-python.dev/zh/docs/configuration/)。 +```sh +basilisk check path/to/your_code.py --output json --color never +``` -## 编辑器 +这两条命令读取的是按来源划分的同一套规则宇宙([`CHKARCH-COMMANDS`](docs/specs/CHECKER-ARCHITECTURE-SPEC.md)):`check` +只报告带 `pep` 标签的类型规范规则 —— 该集合始终启用,配置表虽可将其中某条 +降级为 `warning`/`info`,但都不能将其关闭。`analyze` 报告非 `pep` 的自有规则, +它们在被配置表选用之前始终保持沉默。只有 `analyze` 会输出 `BSK-` 诊断。 -一个扩展,覆盖完整工作流:默认严格的诊断、自动补全、悬停信息、跳转到定义、重构代码操作、调试与性能分析。无需 Node.js 或 Python 运行时 —— 由单一 Rust 二进制文件驱动一切。 +## 标准库类型:始终离线 -- **VS Code、Cursor 与 Windsurf** —— 从 [Open VSX](https://open-vsx.org/) 安装 -- **Zed** • **Neovim 0.10+** +Basilisk 从 [typeshed](https://github.com/python/typeshed) 解析标准库类型, +而且检查**从不下载任何东西**。开箱即用时它使用编译进二进制文件的完整 typeshed +`stdlib/` 快照,并将来源报告为未固定(unpinned)—— 因此在飞机上、防火墙后或 +隔离网络的 CI 中,标准库类型都无需配置即可使用。 -每条诊断都有教育意义:rustc 风格的输出,附带 `help`、`note` 以及指向每条规则详解页的链接。参见[完整诊断参考](https://www.basilisk-python.dev/zh/docs/rules/)与[安装指南](https://www.basilisk-python.dev/zh/docs/installation/)。 +在 `[tool.basilisk]` 中使用 `typeshed-commit = "<40 位 sha>"` 固定到某个确切提交。 +固定只做一件事:离线校验本地存储库中的 typeshed 树是否哈希为该提交。若该提交 +不在本机上,运行会以 `NO SOURCE` 硬失败,而不会替换为其他来源 —— 请先用 +`basilisk typeshed download` 取回(不带 `--commit` 时会下载最新提交并替你写入 +固定项),或使用编辑器中的 **Download latest** 按钮。或者,把 `typeshed-path` +指向你自己的 typeshed 目录树。完整选项参见[配置指南](https://www.basilisk-python.dev/zh/docs/configuration/)。 ## 开发 @@ -132,7 +154,11 @@ Basilisk 建立在开源社区之上 —— 特别感谢: - [`python/typing`](https://github.com/python/typing) 一致性测试套件。 完整的组件、所选许可证与必要声明见 [NOTICES](NOTICES) 和 -[RUST-DEPENDENCY-LICENSES](RUST-DEPENDENCY-LICENSES)。 +[RUST-DEPENDENCY-LICENSES](RUST-DEPENDENCY-LICENSES)。每个发布的产物也各自 +携带副本:VSIX 在 `RUST-DEPENDENCY-LICENSES` 中提供 Rust 声明,在 +`VSCODE-DEPENDENCY-LICENSES` 中提供 npm 声明,并在 `bundled/debugpy` 内保留 +debugpy 自身的许可证与 `ThirdPartyNotices.txt`;wheel 则在 `.dist-info/licenses/` +目录中携带完整的锁定声明。 --- diff --git a/VSCODE-DEPENDENCY-LICENSES b/VSCODE-DEPENDENCY-LICENSES index 225bb1fe..8bc1432e 100644 --- a/VSCODE-DEPENDENCY-LICENSES +++ b/VSCODE-DEPENDENCY-LICENSES @@ -2,7 +2,7 @@ Basilisk VS Code Production Dependency Licenses ================================================= Generated from the exact npm production graph selected by package-lock.json. -Production graph SHA-256: 0cde329d95c6111d14152a9e04f4bd890377383c0fa5994f216c19b3834a0767 +Production graph SHA-256: 43f5f5fd0daae31342024112becb94923452b92c929a2820b1eb50eca7f9f186 Regenerate with: npm run licenses:update =============================================================================== @@ -124,9 +124,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. =============================================================================== -brace-expansion 5.0.6 +brace-expansion 5.0.7 License: MIT -Repository: git+ssh://git@github.com/juliangruber/brace-expansion.git +Repository: git+https://github.com/juliangruber/brace-expansion.git Source: LICENSE SHA-256: 9c63a23124d68cd30cd316a94a1a0bca34f032786df6df69fc4b5f136bac8d2e diff --git a/basilisk.nvim/tests/basilisk/commands_spec.lua b/basilisk.nvim/tests/basilisk/commands_spec.lua index 321cbf88..55ad3fd2 100644 --- a/basilisk.nvim/tests/basilisk/commands_spec.lua +++ b/basilisk.nvim/tests/basilisk/commands_spec.lua @@ -1,5 +1,7 @@ --- Tests for basilisk.commands module. +local command_desc = require("tests.command_desc") + describe("basilisk.commands", function() local commands = require("basilisk.commands") local config = require("basilisk.config") @@ -160,14 +162,19 @@ describe("basilisk.commands", function() describe("command descriptions", function() it("all commands have descriptions", function() local user_commands = vim.api.nvim_get_commands({}) + local checked = 0 for name, cmd in pairs(user_commands) do if name:match("^Basilisk") then - assert.is_true( - cmd.definition ~= nil and cmd.definition ~= "", + assert.is_not_nil( + command_desc.of(cmd), name .. " should have a description" ) + checked = checked + 1 end end + -- Guard the guard: if the prefix match ever stops finding commands, the + -- loop above passes vacuously and the whole check silently disappears. + assert.is_true(checked > 0, "no Basilisk commands were found to check") end) end) end) diff --git a/basilisk.nvim/tests/basilisk/memory_spec.lua b/basilisk.nvim/tests/basilisk/memory_spec.lua index 223280fe..99dde6ba 100644 --- a/basilisk.nvim/tests/basilisk/memory_spec.lua +++ b/basilisk.nvim/tests/basilisk/memory_spec.lua @@ -9,6 +9,8 @@ --- 6. State Management — graceful degradation without LSP client --- 7. Realistic Scenarios — real-world memory leak patterns +local command_desc = require("tests.command_desc") + local function close_all_floats() for _, win in ipairs(vim.api.nvim_list_wins()) do local config = vim.api.nvim_win_get_config(win) @@ -342,7 +344,7 @@ describe("memory — command registration", function() local entry = all_cmds[cmd] assert.truthy(entry, "command '" .. cmd .. "' should exist") assert.truthy( - entry.definition and #entry.definition > 0, + command_desc.of(entry), "command '" .. cmd .. "' should have a description" ) end @@ -384,7 +386,7 @@ describe("memory — command registration", function() local all_cmds = vim.api.nvim_get_commands({}) local entry = all_cmds["BasiliskMemLeak"] assert.truthy(entry) - local desc = entry.definition:lower() + local desc = assert(command_desc.of(entry), "BasiliskMemLeak has no description"):lower() assert.truthy( desc:find("memory") or desc:find("leak"), "description should mention memory or leak" @@ -395,7 +397,7 @@ describe("memory — command registration", function() local all_cmds = vim.api.nvim_get_commands({}) local entry = all_cmds["BasiliskMemRefs"] assert.truthy(entry) - local desc = entry.definition:lower() + local desc = assert(command_desc.of(entry), "BasiliskMemRefs has no description"):lower() assert.truthy( desc:find("reference") or desc:find("memory"), "description should mention references or memory" diff --git a/basilisk.nvim/tests/basilisk/profiling_spec.lua b/basilisk.nvim/tests/basilisk/profiling_spec.lua index 56c536a3..897e1d6e 100644 --- a/basilisk.nvim/tests/basilisk/profiling_spec.lua +++ b/basilisk.nvim/tests/basilisk/profiling_spec.lua @@ -11,6 +11,8 @@ --- 8. Heat Map Extmarks — apply/clear/highlight groups/multi-file --- 9. Flamegraph Export — speedscope JSON temp file, edge cases +local command_desc = require("tests.command_desc") + local function close_all_floats() for _, win in ipairs(vim.api.nvim_list_wins()) do local config = vim.api.nvim_win_get_config(win) @@ -1263,7 +1265,7 @@ describe("profiler — command registration", function() local entry = all_cmds[cmd] assert.truthy(entry, "command '" .. cmd .. "' should exist") assert.truthy( - entry.definition and #entry.definition > 0, + command_desc.of(entry), "command '" .. cmd .. "' should have a description" ) end diff --git a/basilisk.nvim/tests/command_desc.lua b/basilisk.nvim/tests/command_desc.lua new file mode 100644 index 00000000..e64da524 --- /dev/null +++ b/basilisk.nvim/tests/command_desc.lua @@ -0,0 +1,43 @@ +--- Read a user command's description across the Neovim versions CI covers. +--- +--- Supports [NVIM-DISTRIBUTION-CI]: the `test-nvim` matrix spans a Neovim +--- release boundary, and `nvim_get_commands` reports a Lua-callback command's +--- `desc` differently on either side of it: +--- +--- * 0.11 / 0.12 — `definition` carries the description, `desc` is nil. +--- * 0.13-dev — `definition` is the empty string, `desc` carries it. +--- +--- A spec that reads only `definition` therefore does not check the +--- description at all on the nightly leg; it compares "" against its +--- non-empty assertion and fails every command that has a perfectly good +--- `desc`. Reading only `desc` fails the 0.11 leg the same way. Every spec +--- asserting on command descriptions goes through here so the requirement is +--- stated once and holds on both legs. + +local M = {} + +--- The description Neovim reports for one `nvim_get_commands` entry. +--- @param entry table one value from `vim.api.nvim_get_commands({})` +--- @return string|nil description, or nil when the command genuinely has none +function M.of(entry) + if entry == nil then + return nil + end + local text = entry.desc + if text == nil or text == "" then + text = entry.definition + end + if text == nil or text == "" then + return nil + end + return text +end + +--- The description for one command name, or nil if the command is absent. +--- @param name string user command name, e.g. "BasiliskInfo" +--- @return string|nil +function M.for_command(name) + return M.of(vim.api.nvim_get_commands({})[name]) +end + +return M diff --git a/book/manuscript/08-imports-packages-stubs.md b/book/manuscript/08-imports-packages-stubs.md index 385fbd70..b732a651 100644 --- a/book/manuscript/08-imports-packages-stubs.md +++ b/book/manuscript/08-imports-packages-stubs.md @@ -19,11 +19,12 @@ behavior. ## Typeshed and the standard library Explain typeshed's role and which source the documented release actually used. -By default Basilisk verifies `python/typeshed@main` at run time and reports the -result as unpinned; the complete `stdlib/` snapshot compiled into the release is -the offline fallback, not the normal source. Show `typeshed-commit` as the way a -project makes standard-library information reproducible, and state that the pin -fails closed rather than silently substituting another commit. +Checking is offline: by default Basilisk uses the complete `stdlib/` snapshot +compiled into the release and reports it as unpinned. Show `typeshed-commit` as +the way a project makes standard-library information reproducible — a pin +verifies, offline, that the tree in the local store hashes to that commit — and +state that it fails closed with `NO SOURCE` rather than downloading anything or +silently substituting another commit. ## Typed distributions and `py.typed` diff --git a/conformance/test_typeshed_documentation.py b/conformance/test_typeshed_documentation.py index 5870caea..8008333d 100644 --- a/conformance/test_typeshed_documentation.py +++ b/conformance/test_typeshed_documentation.py @@ -67,25 +67,29 @@ def test_six_step_mermaid_flow_is_retained_in_order(self) -> None: self.assertLess(flow.index("5 ·"), flow.index("6 ·")) def test_cache_pin_and_python_target_contract_has_no_contradiction(self) -> None: - """The reuse window is split by whether the selection can move. + """Store entries are commits, so age and the network carry no meaning. - `Latest` tracks the moving `main` reference, so its bytes must keep a - stated expiry — documenting unbounded reuse there would promise a - freshness the checker does not deliver. An exact pin is + The checker never downloads: the only sources are a pinned store entry + and a custom folder, so no document may describe a moving `latest` + selection, a refresh TTL, or any expiry window. A store entry is content-addressed and re-hashed on every load, so age carries no - information about it and the docs must say so rather than implying a - pinned project needs the network. Both halves are asserted here so - neither can be dropped silently. + information about it — reuse is unbounded and re-verified, and the pin + itself never expires or changes. Both the ban on freshness language and + the positive store contract are asserted here so neither can be + dropped silently. """ combined = "\n".join(document.read_text() for document in DOCUMENTS) normalized = re.sub(r"\s+", " ", combined).lower() for forbidden in ( - # Unqualified claims that caching never expires. The pin exemption - # is always stated as scoped to a pin, never as a blanket property. + # Freshness/TTL language: with no downloads there is nothing to + # refresh, so any expiry window is a contradiction. "without a refresh ttl", + "refresh ttl", + "24-hour", + "24 hours", "cached indefinitely", # A pin must never be presented as expiring: that was the defect - # this contract now guards against. + # this contract originally guarded against. "pins expire after 24 hours", "an exact pin expires", # Commit selection is never derived from the Python target. @@ -94,21 +98,18 @@ def test_cache_pin_and_python_target_contract_has_no_contradiction(self) -> None "default 3.12", ): self.assertNotIn(forbidden, normalized) - # The moving reference keeps a stated 24-hour bound. - self.assertIn( - "the 24-hour expiry bounds reuse of unpinned downloaded zip bytes only", - normalized, - ) - self.assertIn("latest expires after 24 hours", normalized) - self.assertIn("after 24 hours", normalized) - # The pin is exempt, and the reason is stated rather than asserted. + # The checker is offline: no download path exists at check time. + self.assertIn("the checker never downloads", normalized) + # Store reuse is unbounded, and the reason is stated rather than + # asserted: every load re-verifies the content address. self.assertIn("reused regardless of age", normalized) + self.assertIn("re-verified by hashing every time", normalized) + self.assertIn("no expiry, no reuse policy, no cache-off mode", normalized) self.assertIn( - "every reuse re-hashes the zip against its recorded sha-256", normalized + "nothing is cached, nothing expires, a pin always verifies", normalized ) # Identity stability is unchanged by any of the above. self.assertIn("the pin never expires or changes", normalized) - self.assertIn("exact commit identity never expires", normalized) if __name__ == "__main__": diff --git a/crates/basilisk-checker/README.md b/crates/basilisk-checker/README.md index eaf676c7..27180e92 100644 --- a/crates/basilisk-checker/README.md +++ b/crates/basilisk-checker/README.md @@ -23,9 +23,9 @@ AST + resolved scopes + effective config ➜ [basilisk-checker] ➜ diagnostics - **Live tagged registry** — provenance, PEP-category, and descriptive tags are attached to rules and drive selection/classification. - **Stub resolution** — resolves types via `basilisk-stubs`: the standard - library from the pinned step-3 typeshed source—custom path, explicit commit, - verified `main`, or complete bundled ZIP snapshot—followed by third-party - packages in the specified order + library from the pinned step-3 typeshed source—a custom path, or a commit + verified offline against the local store, defaulting to the complete bundled + ZIP snapshot—followed by third-party packages in the specified order ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). - **Gradual adoption** — project/path configuration and the LSP adoption store let existing codebases record debt without changing the default rule set. diff --git a/crates/basilisk-checker/src/cached.rs b/crates/basilisk-checker/src/cached.rs index 5ca75b3c..b732cace 100644 --- a/crates/basilisk-checker/src/cached.rs +++ b/crates/basilisk-checker/src/cached.rs @@ -1,5 +1,5 @@ //! Implements [CHKCACHE-DIAG] / [CHKCACHE-DIAG-INTERN]. -//! See docs/specs/CHECKER-CACHE-SPEC.md#chkcache-entry +//! See docs/specs/CHECKER-CACHE-SPEC.md#CHKCACHE-ENTRY //! //! Owned, serde-serialisable projection of [`Diagnostic`] for the result cache. //! diff --git a/crates/basilisk-checker/src/collection_inference.rs b/crates/basilisk-checker/src/collection_inference.rs index db91006c..21661e22 100644 --- a/crates/basilisk-checker/src/collection_inference.rs +++ b/crates/basilisk-checker/src/collection_inference.rs @@ -1,4 +1,4 @@ -//! Implements [TYPEINF-COLLECTIONS] / [TYPEINF-EXCEEDS-CONTAINERS]. See docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md#typeinf-collections +//! Implements [TYPEINF-COLLECTIONS] / [TYPEINF-EXCEEDS-CONTAINERS]. See docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md#TYPEINF-COLLECTIONS //! Collection type inference for lists, dicts, sets, and tuples. //! //! [TYPEINF-EXCEEDS-CONTAINERS]: the union-of-element-types inference below is diff --git a/crates/basilisk-checker/src/diagnostic.rs b/crates/basilisk-checker/src/diagnostic.rs index d59beccb..c454c6e5 100644 --- a/crates/basilisk-checker/src/diagnostic.rs +++ b/crates/basilisk-checker/src/diagnostic.rs @@ -1,4 +1,4 @@ -//! Implements [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Diagnostic data types for Basilisk. use basilisk_resolver::Span; diff --git a/crates/basilisk-checker/src/exports.rs b/crates/basilisk-checker/src/exports.rs index 19412bb6..74b7c54a 100644 --- a/crates/basilisk-checker/src/exports.rs +++ b/crates/basilisk-checker/src/exports.rs @@ -46,6 +46,7 @@ pub fn extract_exports( source_path: source_path.to_path_buf(), source_span: func.name_span, signature: Some(signature), + docstring: func.docstring.clone(), provenance: Some(TypeProvenance::Source), methods: Vec::new(), bases: Vec::new(), @@ -66,6 +67,7 @@ pub fn extract_exports( .map(|func| ExternalMethod { name: func.name.clone(), signature: build_function_signature(func, &resolved.source), + docstring: func.docstring.clone(), }) .collect(); exports.push(( @@ -76,7 +78,8 @@ pub fn extract_exports( type_annotation: None, source_path: source_path.to_path_buf(), source_span: class.name_span, - signature: Some(format!("class {}", class.name)), + signature: Some(class_signature(&class.name, &class.bases)), + docstring: class.docstring.clone(), provenance: Some(TypeProvenance::Source), methods, bases: class.bases.clone(), @@ -101,6 +104,7 @@ pub fn extract_exports( source_path: source_path.to_path_buf(), source_span: var.name_span, signature: None, + docstring: None, provenance: Some(TypeProvenance::Source), methods: Vec::new(), bases: Vec::new(), @@ -191,6 +195,19 @@ fn parse_stub_source( .ok() } +/// The `class` line hover shows for an exported class. +/// +/// The declared bases are part of the class's shape — they are what tells a +/// reader where its inherited members come from — so they are rendered rather +/// than dropped ([LSPARCH-FEATURES-HOVER]). +fn class_signature(name: &str, bases: &[String]) -> String { + if bases.is_empty() { + format!("class {name}") + } else { + format!("class {name}({})", bases.join(", ")) + } +} + fn stub_module_exports( stub: &StubModule, stub_path: &Path, @@ -210,6 +227,7 @@ fn stub_module_exports( source_path: stub_path.to_path_buf(), source_span: Span::new(0, 0), signature: Some(basilisk_stubs::render_stub_signature(func)), + docstring: None, provenance, methods: Vec::new(), bases: Vec::new(), @@ -228,6 +246,7 @@ fn stub_module_exports( .map(|method| ExternalMethod { name: method.name.clone(), signature: basilisk_stubs::render_stub_signature(method), + docstring: None, }) .collect(); exports.push(( @@ -238,7 +257,8 @@ fn stub_module_exports( type_annotation: None, source_path: stub_path.to_path_buf(), source_span: Span::new(0, 0), - signature: Some(format!("class {}", class.name)), + signature: Some(class_signature(&class.name, &class.bases)), + docstring: None, provenance, methods, bases: class.bases.clone(), @@ -258,6 +278,7 @@ fn stub_module_exports( source_path: stub_path.to_path_buf(), source_span: Span::new(0, 0), signature: None, + docstring: None, provenance, methods: Vec::new(), bases: Vec::new(), @@ -457,6 +478,7 @@ pub fn load_external_module_from_source_with_loader( source_path: logical_path.to_path_buf(), source_span: Span::new(0, 0), signature: None, + docstring: None, provenance, methods: Vec::new(), bases: Vec::new(), diff --git a/crates/basilisk-checker/src/imports/resolve_tests.rs b/crates/basilisk-checker/src/imports/resolve_tests.rs index 239f68a8..9751fe59 100644 --- a/crates/basilisk-checker/src/imports/resolve_tests.rs +++ b/crates/basilisk-checker/src/imports/resolve_tests.rs @@ -12,9 +12,7 @@ use std::sync::Arc; use basilisk_stubs::typeshed::archive::{Archive, ArchiveEntry, ArchiveVfs}; use basilisk_stubs::typeshed::gittree::FileMode; use basilisk_stubs::typeshed::snapshot::Snapshot; -use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, Transport, TypeshedStatus, -}; +use basilisk_stubs::typeshed::source::{LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus}; use super::{resolve_module, resolve_module_with_importer, ActiveTypeshed, ImportSearchPaths}; @@ -65,11 +63,8 @@ fn custom_snapshot(modules: &[(&str, &str)]) -> ActiveTypeshed { active_source: SourceKind::Custom, commit: None, tree: None, - transport: Transport::CustomPath, license_status: LicenseStatus::NotSupplied, license_reference: None, - provenance: Provenance::UserManaged, - signed_release: false, warnings: Vec::new(), }; let snapshot = Snapshot::build( diff --git a/crates/basilisk-checker/src/lib.rs b/crates/basilisk-checker/src/lib.rs index 8181da84..65d40a3b 100644 --- a/crates/basilisk-checker/src/lib.rs +++ b/crates/basilisk-checker/src/lib.rs @@ -1,6 +1,6 @@ //! Implements [CHKARCH-ARCH-PIPELINE] and integrates the modules specified by -//! [TYPEINF-SPEC] / [TYPEINF-IMPL]. See -//! docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-arch-pipeline and +//! [TYPEINF] / [TYPEINF-IMPL]. See +//! docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-ARCH-PIPELINE and //! docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md#TYPEINF-IMPL. //! Type checker for Basilisk. //! diff --git a/crates/basilisk-checker/src/param_infer.rs b/crates/basilisk-checker/src/param_infer.rs index 2f6ab35c..2823f774 100644 --- a/crates/basilisk-checker/src/param_infer.rs +++ b/crates/basilisk-checker/src/param_infer.rs @@ -296,6 +296,7 @@ mod imported_globals_tests { source_path: std::path::PathBuf::from("other.py"), source_span: Span::new(0, 0), signature: None, + docstring: None, provenance: None, methods: Vec::new(), bases: Vec::new(), diff --git a/crates/basilisk-checker/src/rule_tags.rs b/crates/basilisk-checker/src/rule_tags.rs index 54698e46..cca7dd0c 100644 --- a/crates/basilisk-checker/src/rule_tags.rs +++ b/crates/basilisk-checker/src/rule_tags.rs @@ -1,4 +1,4 @@ -//! Implements [CHKTAG] from [CHKARCH-DIAG]. See docs/specs/CHECKER-RULE-TAGGING-SPEC.md#chktag +//! Implements [CHKTAG] from [CHKARCH-DIAG]. See docs/specs/CHECKER-RULE-TAGGING-SPEC.md#CHKTAG //! //! Rule tagging. Basilisk classifies every rule with a flat set of string //! *tags*, not a hierarchical category system. Each rule carries exactly one diff --git a/crates/basilisk-checker/src/rules/aliases_implicit.rs b/crates/basilisk-checker/src/rules/aliases_implicit.rs index 7dfbc669..a42ec46b 100644 --- a/crates/basilisk-checker/src/rules/aliases_implicit.rs +++ b/crates/basilisk-checker/src/rules/aliases_implicit.rs @@ -1,4 +1,4 @@ -//! Implements [`aliases_implicit`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`aliases_implicit`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! `aliases_implicit`: Invalid right-hand side for a `TypeAlias` annotation. //! //! PEP 613 requires that the RHS of an explicit `TypeAlias` annotation must be diff --git a/crates/basilisk-checker/src/rules/aliases_newtype.rs b/crates/basilisk-checker/src/rules/aliases_newtype.rs index 7213764d..8abf83f7 100644 --- a/crates/basilisk-checker/src/rules/aliases_newtype.rs +++ b/crates/basilisk-checker/src/rules/aliases_newtype.rs @@ -1,4 +1,4 @@ -//! Implements [`aliases_newtype`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [`aliases_newtype`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! `aliases_newtype`: Invalid `NewType(...)` call. //! //! PEP 484 places restrictions on `NewType`: diff --git a/crates/basilisk-checker/src/rules/aliases_recursive.rs b/crates/basilisk-checker/src/rules/aliases_recursive.rs index 6b58caa6..88982676 100644 --- a/crates/basilisk-checker/src/rules/aliases_recursive.rs +++ b/crates/basilisk-checker/src/rules/aliases_recursive.rs @@ -1,4 +1,4 @@ -//! Implements [`aliases_recursive`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`aliases_recursive`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `aliases_recursive`: Cyclical type alias reference. //! //! A `TypeAlias`-annotated assignment whose RHS contains a forward-reference diff --git a/crates/basilisk-checker/src/rules/aliases_type_statement.rs b/crates/basilisk-checker/src/rules/aliases_type_statement.rs index a60d0bcb..e54c66c1 100644 --- a/crates/basilisk-checker/src/rules/aliases_type_statement.rs +++ b/crates/basilisk-checker/src/rules/aliases_type_statement.rs @@ -1,4 +1,4 @@ -//! Implements [`aliases_type_statement`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [`aliases_type_statement`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! `aliases_type_statement`: Invalid RHS in a PEP 695 `type X = rhs` statement. //! //! PEP 695 requires the RHS of a `type` statement to be a valid type expression. diff --git a/crates/basilisk-checker/src/rules/aliases_typealiastype.rs b/crates/basilisk-checker/src/rules/aliases_typealiastype.rs index d52b087f..9c05a09d 100644 --- a/crates/basilisk-checker/src/rules/aliases_typealiastype.rs +++ b/crates/basilisk-checker/src/rules/aliases_typealiastype.rs @@ -1,4 +1,4 @@ -//! Implements [`aliases_typealiastype`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`aliases_typealiastype`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `aliases_typealiastype`: Invalid `TypeAliasType(...)` call. //! //! Detects violations in `TypeAliasType(...)` calls: diff --git a/crates/basilisk-checker/src/rules/annotations_forward_refs/scope.rs b/crates/basilisk-checker/src/rules/annotations_forward_refs/scope.rs index 127f5523..f0cd2a47 100644 --- a/crates/basilisk-checker/src/rules/annotations_forward_refs/scope.rs +++ b/crates/basilisk-checker/src/rules/annotations_forward_refs/scope.rs @@ -1,4 +1,4 @@ -//! Implements [`annotations_forward_refs`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`annotations_forward_refs`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! Module scope and circular annotation helpers for `annotations_forward_refs`. //! //! Contains helpers for building module scope name sets, detecting circular diff --git a/crates/basilisk-checker/src/rules/annotations_forward_refs/type_checks.rs b/crates/basilisk-checker/src/rules/annotations_forward_refs/type_checks.rs index e45072b3..b02256f3 100644 --- a/crates/basilisk-checker/src/rules/annotations_forward_refs/type_checks.rs +++ b/crates/basilisk-checker/src/rules/annotations_forward_refs/type_checks.rs @@ -1,4 +1,4 @@ -//! Implements [`annotations_forward_refs`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`annotations_forward_refs`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! Structural annotation validity checks for `annotations_forward_refs`. //! //! Contains pure functions for detecting structurally invalid type expressions diff --git a/crates/basilisk-checker/src/rules/annotations_generators.rs b/crates/basilisk-checker/src/rules/annotations_generators.rs index a10eee31..946db4ba 100644 --- a/crates/basilisk-checker/src/rules/annotations_generators.rs +++ b/crates/basilisk-checker/src/rules/annotations_generators.rs @@ -1,4 +1,4 @@ -//! Implements [`annotations_generators`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`annotations_generators`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `annotations_generators`: Generator return type and yield type violations. //! //! A generator function (one containing `yield` or `yield from`) must declare diff --git a/crates/basilisk-checker/src/rules/annotations_generators_2/annotation.rs b/crates/basilisk-checker/src/rules/annotations_generators_2/annotation.rs index cbb50353..98078db6 100644 --- a/crates/basilisk-checker/src/rules/annotations_generators_2/annotation.rs +++ b/crates/basilisk-checker/src/rules/annotations_generators_2/annotation.rs @@ -1,4 +1,4 @@ -//! Implements [`annotations_generators_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`annotations_generators_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Annotation parsing for `annotations_generators_2`. //! //! Provides utilities for recognising and decomposing generator-like return diff --git a/crates/basilisk-checker/src/rules/annotations_generators_2/type_check.rs b/crates/basilisk-checker/src/rules/annotations_generators_2/type_check.rs index 9fb47033..822614ec 100644 --- a/crates/basilisk-checker/src/rules/annotations_generators_2/type_check.rs +++ b/crates/basilisk-checker/src/rules/annotations_generators_2/type_check.rs @@ -1,4 +1,4 @@ -//! Implements [`annotations_generators_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`annotations_generators_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Type checking helpers for `annotations_generators_2`. //! //! Contains compatibility checks for yield/send/return types and the diff --git a/crates/basilisk-checker/src/rules/annotations_generators_2/yield_scan.rs b/crates/basilisk-checker/src/rules/annotations_generators_2/yield_scan.rs index 5107ca07..957de02e 100644 --- a/crates/basilisk-checker/src/rules/annotations_generators_2/yield_scan.rs +++ b/crates/basilisk-checker/src/rules/annotations_generators_2/yield_scan.rs @@ -1,4 +1,4 @@ -//! Implements [`annotations_generators_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`annotations_generators_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Yield expression scanning for `annotations_generators_2`. //! //! Provides a byte-level scanner that finds `yield` and `yield from` diff --git a/crates/basilisk-checker/src/rules/annotations_generators_helpers.rs b/crates/basilisk-checker/src/rules/annotations_generators_helpers.rs index 7f7156d8..e23340c5 100644 --- a/crates/basilisk-checker/src/rules/annotations_generators_helpers.rs +++ b/crates/basilisk-checker/src/rules/annotations_generators_helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`annotations_generators`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`annotations_generators`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Helper functions for `annotations_generators`: Generator return type and yield type //! violations. //! diff --git a/crates/basilisk-checker/src/rules/annotations_typeexpr.rs b/crates/basilisk-checker/src/rules/annotations_typeexpr.rs index a6b18b83..74b3794d 100644 --- a/crates/basilisk-checker/src/rules/annotations_typeexpr.rs +++ b/crates/basilisk-checker/src/rules/annotations_typeexpr.rs @@ -1,4 +1,4 @@ -//! Implements [`annotations_typeexpr`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`annotations_typeexpr`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `annotations_typeexpr`: Invalid type form — numeric literal used as type annotation. //! //! Type annotations must be type expressions, not literal values. Using a diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/alias_match.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/alias_match.rs index 8c93aa41..9abe985f 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/alias_match.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/alias_match.rs @@ -1,4 +1,4 @@ -//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! Recursive type-alias value matching for `assignment_compatibility`. //! //! Legacy type aliases such as diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/callable_check.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/callable_check.rs index e31037a1..325e8e1a 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/callable_check.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/callable_check.rs @@ -1,4 +1,4 @@ -//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Structural callable subtyping for callback protocols and `Callable` forms. //! //! E0014's name-based comparison cannot evaluate structural compatibility diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/dataclass_check.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/dataclass_check.rs index 9bf53829..814c3345 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/dataclass_check.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/dataclass_check.rs @@ -1,4 +1,4 @@ -//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! Dataclass attribute assignment checking for `assignment_compatibility`. //! //! Validates module-level attribute assignments (`instance.field = value`) diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/default_spec.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/default_spec.rs index ad4cc887..e338f1fa 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/default_spec.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/default_spec.rs @@ -1,4 +1,4 @@ -//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! PEP 696 default-specialization mismatch for bare generic class assignments. //! //! A bare reference to a generic class whose remaining free type parameters diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/literal_parse.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/literal_parse.rs index ead48737..0e73abf7 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/literal_parse.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/literal_parse.rs @@ -1,4 +1,4 @@ -//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! Literal value parsing for `assignment_compatibility`. //! //! Provides functions that parse source-text representations of Python literals diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/protocol_members.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/protocol_members.rs index 50f952b3..06ab7ab0 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/protocol_members.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/protocol_members.rs @@ -1,4 +1,4 @@ -//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Member-based protocol satisfaction (PEP 544): a class satisfies a protocol //! when it provides every protocol member with a structurally compatible type. diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/sig_model.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/sig_model.rs index 336b6556..24715e19 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/sig_model.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/sig_model.rs @@ -1,4 +1,4 @@ -//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Signature model for structural callable/protocol subtyping: parameter and //! signature types, plus extraction from `ruff` AST class/function definitions. diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/sig_subtype.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/sig_subtype.rs index 6fe3dcac..2789f6aa 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/sig_subtype.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/sig_subtype.rs @@ -1,4 +1,4 @@ -//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! The callable-subtyping algorithm from the typing spec: positional/keyword //! matching, `*args`/`**kwargs` contravariance, defaults, and gradual forms. diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/tuple_check.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/tuple_check.rs index e8037501..a4fa8f71 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/tuple_check.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/tuple_check.rs @@ -1,4 +1,4 @@ -//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! Tuple assignment checking for `assignment_compatibility`. //! //! Validates that re-assignments to tuple-annotated module variables are diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/typeddict_struct.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/typeddict_struct.rs index d99a8040..dbc4c7ea 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/typeddict_struct.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/typeddict_struct.rs @@ -1,5 +1,5 @@ //! Implements [`assignment_compatibility`] from [CHKARCH-DIAG]. See -//! docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! //! PEP 705-aware structural assignability for `TypedDict`-to-`TypedDict` //! assignments. E0014's default `Named`-vs-`Named` comparison is name equality, diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/typeform_check.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/typeform_check.rs index 86d3ed9b..33fd08d7 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/typeform_check.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/typeform_check.rs @@ -1,4 +1,4 @@ -//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `TypeForm` validation for `assignment_compatibility`. //! //! When the declared type is `TypeForm[T]`, the RHS must be a valid type diff --git a/crates/basilisk-checker/src/rules/callables_annotation.rs b/crates/basilisk-checker/src/rules/callables_annotation.rs index e9d94f76..8a9bd10f 100644 --- a/crates/basilisk-checker/src/rules/callables_annotation.rs +++ b/crates/basilisk-checker/src/rules/callables_annotation.rs @@ -1,4 +1,4 @@ -//! Implements [`callables_annotation`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`callables_annotation`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `callables_annotation`: Invalid type argument count or form. //! //! Certain generic types accept a fixed number of type arguments. This rule diff --git a/crates/basilisk-checker/src/rules/callables_kwargs.rs b/crates/basilisk-checker/src/rules/callables_kwargs.rs index 863c48ed..c9d96fa4 100644 --- a/crates/basilisk-checker/src/rules/callables_kwargs.rs +++ b/crates/basilisk-checker/src/rules/callables_kwargs.rs @@ -1,4 +1,4 @@ -//! Implements [`callables_kwargs`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`callables_kwargs`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `callables_kwargs`: Unpack[`TypedDict`] kwargs violations. //! //! Detects invalid uses of `**kwargs: Unpack[TypedDict]` in function signatures: diff --git a/crates/basilisk-checker/src/rules/callables_protocol/hof_paramspec.rs b/crates/basilisk-checker/src/rules/callables_protocol/hof_paramspec.rs index 55dd326e..0de98d39 100644 --- a/crates/basilisk-checker/src/rules/callables_protocol/hof_paramspec.rs +++ b/crates/basilisk-checker/src/rules/callables_protocol/hof_paramspec.rs @@ -1,4 +1,4 @@ -//! Implements [`callables_protocol`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`callables_protocol`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Higher-order `ParamSpec` argument validation (PEP 612). //! Implements [TYPEINF-GENERICS-PARAMSPEC]. //! diff --git a/crates/basilisk-checker/src/rules/callables_protocol/mod.rs b/crates/basilisk-checker/src/rules/callables_protocol/mod.rs index 321b19e7..f8b26659 100644 --- a/crates/basilisk-checker/src/rules/callables_protocol/mod.rs +++ b/crates/basilisk-checker/src/rules/callables_protocol/mod.rs @@ -1,4 +1,4 @@ -//! Implements [`callables_protocol`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`callables_protocol`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `callables_protocol`: Callable call-site arity and argument validation. //! //! When a parameter is annotated as `Callable[[int, str], T]`, calls to that diff --git a/crates/basilisk-checker/src/rules/callables_protocol/paramspec_components.rs b/crates/basilisk-checker/src/rules/callables_protocol/paramspec_components.rs index bde73fa8..3747224d 100644 --- a/crates/basilisk-checker/src/rules/callables_protocol/paramspec_components.rs +++ b/crates/basilisk-checker/src/rules/callables_protocol/paramspec_components.rs @@ -1,4 +1,4 @@ -//! Implements [`callables_protocol`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`callables_protocol`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `ParamSpec` component rules (PEP 612): `P.args` / `P.kwargs` placement, //! scoping, and transmission through `*args` / `**kwargs` forwarding calls. diff --git a/crates/basilisk-checker/src/rules/callables_protocol_2/callable.rs b/crates/basilisk-checker/src/rules/callables_protocol_2/callable.rs index 4173841c..d8476f8f 100644 --- a/crates/basilisk-checker/src/rules/callables_protocol_2/callable.rs +++ b/crates/basilisk-checker/src/rules/callables_protocol_2/callable.rs @@ -1,4 +1,4 @@ -//! Implements [`callables_protocol_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`callables_protocol_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Callable type parsing and compatibility checking for `callables_protocol_2`. use basilisk_resolver::Span; diff --git a/crates/basilisk-checker/src/rules/callables_protocol_2/checks.rs b/crates/basilisk-checker/src/rules/callables_protocol_2/checks.rs index 35e445f8..4438d7d4 100644 --- a/crates/basilisk-checker/src/rules/callables_protocol_2/checks.rs +++ b/crates/basilisk-checker/src/rules/callables_protocol_2/checks.rs @@ -1,4 +1,4 @@ -//! Implements [`callables_protocol_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`callables_protocol_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Compatibility check functions for `callables_protocol_2`. use std::collections::HashMap; diff --git a/crates/basilisk-checker/src/rules/callables_protocol_2/context.rs b/crates/basilisk-checker/src/rules/callables_protocol_2/context.rs index 29eecee7..08d2cbea 100644 --- a/crates/basilisk-checker/src/rules/callables_protocol_2/context.rs +++ b/crates/basilisk-checker/src/rules/callables_protocol_2/context.rs @@ -1,4 +1,4 @@ -//! Implements [`callables_protocol_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`callables_protocol_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Module context, function signatures, and protocol info for `callables_protocol_2`. use ruff_python_ast::{self as ast, Expr, Stmt}; diff --git a/crates/basilisk-checker/src/rules/callables_protocol_2/protocol.rs b/crates/basilisk-checker/src/rules/callables_protocol_2/protocol.rs index 66a74fb5..e10aee00 100644 --- a/crates/basilisk-checker/src/rules/callables_protocol_2/protocol.rs +++ b/crates/basilisk-checker/src/rules/callables_protocol_2/protocol.rs @@ -1,4 +1,4 @@ -//! Implements [`callables_protocol_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`callables_protocol_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Protocol compatibility checking for `callables_protocol_2`. use basilisk_resolver::Span; diff --git a/crates/basilisk-checker/src/rules/callables_subtyping.rs b/crates/basilisk-checker/src/rules/callables_subtyping.rs index bc22b6e6..ce20c78c 100644 --- a/crates/basilisk-checker/src/rules/callables_subtyping.rs +++ b/crates/basilisk-checker/src/rules/callables_subtyping.rs @@ -1,4 +1,4 @@ -//! Implements [`callables_subtyping`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`callables_subtyping`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `callables_subtyping`: Callable subtyping violations (covariance / contravariance). //! //! Callable types are covariant with respect to return types and contravariant diff --git a/crates/basilisk-checker/src/rules/calls_argument_count.rs b/crates/basilisk-checker/src/rules/calls_argument_count.rs index daa14a97..fbdb29b7 100644 --- a/crates/basilisk-checker/src/rules/calls_argument_count.rs +++ b/crates/basilisk-checker/src/rules/calls_argument_count.rs @@ -1,4 +1,4 @@ -//! Implements [`calls_argument_count`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`calls_argument_count`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! `calls_argument_count`: Too few arguments in a function call. //! //! When a function is called with fewer positional arguments than it has diff --git a/crates/basilisk-checker/src/rules/calls_argument_type.rs b/crates/basilisk-checker/src/rules/calls_argument_type.rs index 269fa043..e77b242f 100644 --- a/crates/basilisk-checker/src/rules/calls_argument_type.rs +++ b/crates/basilisk-checker/src/rules/calls_argument_type.rs @@ -1,4 +1,4 @@ -//! Implements [`calls_argument_type`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`calls_argument_type`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `calls_argument_type`: Argument type mismatch at a call site. //! //! When a function is called with a literal argument whose type is clearly diff --git a/crates/basilisk-checker/src/rules/classes_classvar/args.rs b/crates/basilisk-checker/src/rules/classes_classvar/args.rs index 0fc74a7c..9de4ac6a 100644 --- a/crates/basilisk-checker/src/rules/classes_classvar/args.rs +++ b/crates/basilisk-checker/src/rules/classes_classvar/args.rs @@ -1,4 +1,4 @@ -//! Implements [`classes_classvar`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`classes_classvar`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! Argument validation helpers for `classes_classvar`: `ClassVar` argument correctness //! checks and type-mismatch detection between the `ClassVar` inner type and the RHS. diff --git a/crates/basilisk-checker/src/rules/classes_classvar/helpers.rs b/crates/basilisk-checker/src/rules/classes_classvar/helpers.rs index 37c8cba3..54964065 100644 --- a/crates/basilisk-checker/src/rules/classes_classvar/helpers.rs +++ b/crates/basilisk-checker/src/rules/classes_classvar/helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`classes_classvar`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`classes_classvar`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! Shared helper utilities for `classes_classvar`: text-based `ClassVar` detection, //! diagnostic construction, and the `TypeParamKind` classification enum. diff --git a/crates/basilisk-checker/src/rules/classes_classvar/instance.rs b/crates/basilisk-checker/src/rules/classes_classvar/instance.rs index 48243c70..07fe1abf 100644 --- a/crates/basilisk-checker/src/rules/classes_classvar/instance.rs +++ b/crates/basilisk-checker/src/rules/classes_classvar/instance.rs @@ -1,4 +1,4 @@ -//! Implements [`classes_classvar`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`classes_classvar`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! Instance-level `ClassVar` violation checks for `classes_classvar`. //! //! Handles two cases: diff --git a/crates/basilisk-checker/src/rules/classes_classvar/protocol.rs b/crates/basilisk-checker/src/rules/classes_classvar/protocol.rs index 59912db2..fbdf7e74 100644 --- a/crates/basilisk-checker/src/rules/classes_classvar/protocol.rs +++ b/crates/basilisk-checker/src/rules/classes_classvar/protocol.rs @@ -1,4 +1,4 @@ -//! Implements [`classes_classvar`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`classes_classvar`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! Protocol `ClassVar` conformance checks for `classes_classvar`. //! //! When a variable is typed as a `Protocol` with `ClassVar` attributes, the RHS diff --git a/crates/basilisk-checker/src/rules/classes_override.rs b/crates/basilisk-checker/src/rules/classes_override.rs index e1a1fe7b..a54f701d 100644 --- a/crates/basilisk-checker/src/rules/classes_override.rs +++ b/crates/basilisk-checker/src/rules/classes_override.rs @@ -1,4 +1,4 @@ -//! Implements [`classes_override`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`classes_override`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `classes_override`: Incompatible method override. //! //! When a class method marked with `@override` has a different parameter diff --git a/crates/basilisk-checker/src/rules/classes_override_2.rs b/crates/basilisk-checker/src/rules/classes_override_2.rs index 2036019f..3083a069 100644 --- a/crates/basilisk-checker/src/rules/classes_override_2.rs +++ b/crates/basilisk-checker/src/rules/classes_override_2.rs @@ -1,4 +1,4 @@ -//! Implements [`classes_override_2`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`classes_override_2`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `classes_override_2`: Incompatible class attribute override. //! //! When a child class declares an attribute that also exists in a same-module diff --git a/crates/basilisk-checker/src/rules/classes_override_3.rs b/crates/basilisk-checker/src/rules/classes_override_3.rs index 40e2936f..0949c137 100644 --- a/crates/basilisk-checker/src/rules/classes_override_3.rs +++ b/crates/basilisk-checker/src/rules/classes_override_3.rs @@ -1,4 +1,4 @@ -//! Implements [`classes_override_3`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`classes_override_3`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `classes_override_3`: `@override` on a method with no matching ancestor method. //! //! PEP 698 — a method decorated `@override` (or `typing.override`) must actually diff --git a/crates/basilisk-checker/src/rules/constructors_call_init/helpers.rs b/crates/basilisk-checker/src/rules/constructors_call_init/helpers.rs index f0e7804d..936e2fbb 100644 --- a/crates/basilisk-checker/src/rules/constructors_call_init/helpers.rs +++ b/crates/basilisk-checker/src/rules/constructors_call_init/helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`constructors_call_init`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`constructors_call_init`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Helper functions for `constructors_call_init`: Constructor call errors. use std::collections::HashMap; diff --git a/crates/basilisk-checker/src/rules/constructors_call_new.rs b/crates/basilisk-checker/src/rules/constructors_call_new.rs index ad484f63..46f71b6a 100644 --- a/crates/basilisk-checker/src/rules/constructors_call_new.rs +++ b/crates/basilisk-checker/src/rules/constructors_call_new.rs @@ -1,4 +1,4 @@ -//! Implements [`constructors_call_new`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-optional +//! Implements [`constructors_call_new`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OPTIONAL //! `constructors_call_new`: Constructor call type mismatch with specialized generic class. //! //! When a generic class is called with explicit type arguments (e.g. diff --git a/crates/basilisk-checker/src/rules/constructors_call_type/helpers.rs b/crates/basilisk-checker/src/rules/constructors_call_type/helpers.rs index 2cc8affc..f17c6644 100644 --- a/crates/basilisk-checker/src/rules/constructors_call_type/helpers.rs +++ b/crates/basilisk-checker/src/rules/constructors_call_type/helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`constructors_call_type`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`constructors_call_type`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Helper types and functions for `constructors_call_type`. //! //! Contains constructor signature resolution, argument type checking, diff --git a/crates/basilisk-checker/src/rules/constructors_callable.rs b/crates/basilisk-checker/src/rules/constructors_callable.rs index 90dc8856..1c98a3f9 100644 --- a/crates/basilisk-checker/src/rules/constructors_callable.rs +++ b/crates/basilisk-checker/src/rules/constructors_callable.rs @@ -1,4 +1,4 @@ -//! Implements [`constructors_callable`] from [CHKARCH-DIAG-CTOR-CALLABLE]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ctor-callable +//! Implements [`constructors_callable`] from [CHKARCH-DIAG-CTOR-CALLABLE]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-CTOR-CALLABLE //! `constructors_callable`: Invalid call to a constructor-derived callable. //! //! Implements the typing spec rule "Converting a constructor to callable" diff --git a/crates/basilisk-checker/src/rules/dataclasses_frozen.rs b/crates/basilisk-checker/src/rules/dataclasses_frozen.rs index 2debbe0b..10736381 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_frozen.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_frozen.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_frozen`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [`dataclasses_frozen`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! `dataclasses_frozen`: Assignment to attribute of a frozen dataclass instance, or invalid //! frozen/non-frozen dataclass inheritance. //! diff --git a/crates/basilisk-checker/src/rules/dataclasses_hash.rs b/crates/basilisk-checker/src/rules/dataclasses_hash.rs index d76c8b54..797b108f 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_hash.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_hash.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_hash`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-coercion +//! Implements [`dataclasses_hash`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-COERCION //! `dataclasses_hash`: Non-hashable dataclass assigned to a `Hashable`-annotated variable. //! //! A `@dataclass` with `eq=True` (the default) sets `__hash__` to `None` unless diff --git a/crates/basilisk-checker/src/rules/dataclasses_inheritance.rs b/crates/basilisk-checker/src/rules/dataclasses_inheritance.rs index 2ad00d9b..4d38ecc1 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_inheritance.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_inheritance.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_inheritance`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`dataclasses_inheritance`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `dataclasses_inheritance`: Dataclass field without a default after a field with a default. //! //! A dataclass synthesizes an `__init__` whose parameters follow field diff --git a/crates/basilisk-checker/src/rules/dataclasses_kwonly.rs b/crates/basilisk-checker/src/rules/dataclasses_kwonly.rs index 073225f0..dfd652dc 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_kwonly.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_kwonly.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_kwonly`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-coercion +//! Implements [`dataclasses_kwonly`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-COERCION //! `dataclasses_kwonly`: Dataclass constructor argument violations. //! //! Reports errors when: diff --git a/crates/basilisk-checker/src/rules/dataclasses_match_args.rs b/crates/basilisk-checker/src/rules/dataclasses_match_args.rs index 7d07c7c4..c3f5b7bc 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_match_args.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_match_args.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_match_args`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [`dataclasses_match_args`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! `dataclasses_match_args`: Access to `__match_args__` on a dataclass with `match_args=False`. //! //! When `@dataclass(match_args=False)` is specified, Python does **not** generate diff --git a/crates/basilisk-checker/src/rules/dataclasses_order.rs b/crates/basilisk-checker/src/rules/dataclasses_order.rs index 2c754e8c..967f5a22 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_order.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_order.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_order`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-coercion +//! Implements [`dataclasses_order`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-COERCION //! `dataclasses_order`: Invalid ordering comparison of dataclass instances. //! //! When `@dataclass(order=True)`, Python synthesizes `__lt__`, `__le__`, `__gt__`, diff --git a/crates/basilisk-checker/src/rules/dataclasses_postinit.rs b/crates/basilisk-checker/src/rules/dataclasses_postinit.rs index 5b31c828..05b4bd1a 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_postinit.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_postinit.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_postinit`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`dataclasses_postinit`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `dataclasses_postinit`: `InitVar` field validation in dataclasses. //! //! Detects two categories of `InitVar` violations: diff --git a/crates/basilisk-checker/src/rules/dataclasses_slots.rs b/crates/basilisk-checker/src/rules/dataclasses_slots.rs index ac45af47..5768ba3d 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_slots.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_slots.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_slots`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`dataclasses_slots`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `dataclasses_slots`: Dataclass slots violations. //! //! Reports errors when: diff --git a/crates/basilisk-checker/src/rules/dataclasses_transform_class/converter.rs b/crates/basilisk-checker/src/rules/dataclasses_transform_class/converter.rs index 812632bf..8e72471b 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_transform_class/converter.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_transform_class/converter.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_transform_class`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`dataclasses_transform_class`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `dataclass_transform` field-specifier `converter` support (PEP 681). //! //! When a field specifier call carries `converter=fn`, the type accepted by diff --git a/crates/basilisk-checker/src/rules/dataclasses_transform_class/helpers.rs b/crates/basilisk-checker/src/rules/dataclasses_transform_class/helpers.rs index aa4430e8..65b05812 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_transform_class/helpers.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_transform_class/helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_transform_class`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`dataclasses_transform_class`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Helper types and functions for `dataclasses_transform_class`. //! //! Contains data types for transform-class settings, source text parsing diff --git a/crates/basilisk-checker/src/rules/dataclasses_transform_meta/helpers.rs b/crates/basilisk-checker/src/rules/dataclasses_transform_meta/helpers.rs index 23061779..b83c65e3 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_transform_meta/helpers.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_transform_meta/helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_transform_meta`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`dataclasses_transform_meta`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Helper types and AST collection functions for `dataclasses_transform_meta`. //! //! Contains data types describing `@dataclass_transform` metaclasses and diff --git a/crates/basilisk-checker/src/rules/dataclasses_usage.rs b/crates/basilisk-checker/src/rules/dataclasses_usage.rs index 0db83063..30d4120c 100644 --- a/crates/basilisk-checker/src/rules/dataclasses_usage.rs +++ b/crates/basilisk-checker/src/rules/dataclasses_usage.rs @@ -1,4 +1,4 @@ -//! Implements [`dataclasses_usage`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`dataclasses_usage`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `dataclasses_usage`: Type mismatch between a dataclass `field(default_factory=…)` and //! the field's declared type annotation. //! diff --git a/crates/basilisk-checker/src/rules/dict_key_hashable.rs b/crates/basilisk-checker/src/rules/dict_key_hashable.rs index e2f680f3..de2e8215 100644 --- a/crates/basilisk-checker/src/rules/dict_key_hashable.rs +++ b/crates/basilisk-checker/src/rules/dict_key_hashable.rs @@ -1,4 +1,4 @@ -//! Implements [`dict_key_hashable`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`dict_key_hashable`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `dict_key_hashable`: Unhashable type used as a dict key. //! //! Lists, sets, and plain dicts are not hashable and cannot be used as diff --git a/crates/basilisk-checker/src/rules/directives_assert_type.rs b/crates/basilisk-checker/src/rules/directives_assert_type.rs index 7ba81dd9..90fd3be4 100644 --- a/crates/basilisk-checker/src/rules/directives_assert_type.rs +++ b/crates/basilisk-checker/src/rules/directives_assert_type.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_assert_type`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`directives_assert_type`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `directives_assert_type`: Invalid `assert_type()` call. //! //! `assert_type(expr, Type)` must be called with exactly 2 positional arguments. diff --git a/crates/basilisk-checker/src/rules/directives_assert_type_2.rs b/crates/basilisk-checker/src/rules/directives_assert_type_2.rs index afd36761..3a18ab43 100644 --- a/crates/basilisk-checker/src/rules/directives_assert_type_2.rs +++ b/crates/basilisk-checker/src/rules/directives_assert_type_2.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_assert_type_2`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [`directives_assert_type_2`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! `directives_assert_type_2`: `assert_type()` type mismatch. //! //! `assert_type(expr, Type)` is a static-analysis directive that verifies the diff --git a/crates/basilisk-checker/src/rules/directives_cast.rs b/crates/basilisk-checker/src/rules/directives_cast.rs index 27bb903f..71bc2524 100644 --- a/crates/basilisk-checker/src/rules/directives_cast.rs +++ b/crates/basilisk-checker/src/rules/directives_cast.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_cast`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`directives_cast`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `directives_cast`: Invalid `cast()` call. //! //! `typing.cast(typ, val)` must be called with exactly two positional arguments, diff --git a/crates/basilisk-checker/src/rules/directives_deprecated/collect.rs b/crates/basilisk-checker/src/rules/directives_deprecated/collect.rs index 547acf5e..49949871 100644 --- a/crates/basilisk-checker/src/rules/directives_deprecated/collect.rs +++ b/crates/basilisk-checker/src/rules/directives_deprecated/collect.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_deprecated`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`directives_deprecated`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Collection helpers for `directives_deprecated`. //! //! Functions that scan an AST or source text to build maps of deprecated diff --git a/crates/basilisk-checker/src/rules/directives_deprecated/decorators.rs b/crates/basilisk-checker/src/rules/directives_deprecated/decorators.rs index 7ddffbd1..d9a9fc97 100644 --- a/crates/basilisk-checker/src/rules/directives_deprecated/decorators.rs +++ b/crates/basilisk-checker/src/rules/directives_deprecated/decorators.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_deprecated`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`directives_deprecated`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Decorator detection helpers for `directives_deprecated`. use ruff_python_ast::Expr; diff --git a/crates/basilisk-checker/src/rules/directives_deprecated/types.rs b/crates/basilisk-checker/src/rules/directives_deprecated/types.rs index 876382d8..772049e7 100644 --- a/crates/basilisk-checker/src/rules/directives_deprecated/types.rs +++ b/crates/basilisk-checker/src/rules/directives_deprecated/types.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_deprecated`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`directives_deprecated`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Shared data types for `directives_deprecated`. use std::collections::{HashMap, HashSet}; diff --git a/crates/basilisk-checker/src/rules/directives_deprecated/visit_expr.rs b/crates/basilisk-checker/src/rules/directives_deprecated/visit_expr.rs index e283f45d..ce87769c 100644 --- a/crates/basilisk-checker/src/rules/directives_deprecated/visit_expr.rs +++ b/crates/basilisk-checker/src/rules/directives_deprecated/visit_expr.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_deprecated`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`directives_deprecated`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Expression visitors and helpers for `directives_deprecated`. //! //! Contains `visit_expr_for_usage` and all expression-level deprecation checks. diff --git a/crates/basilisk-checker/src/rules/directives_deprecated/visit_stmt.rs b/crates/basilisk-checker/src/rules/directives_deprecated/visit_stmt.rs index a959e16e..23ff77b1 100644 --- a/crates/basilisk-checker/src/rules/directives_deprecated/visit_stmt.rs +++ b/crates/basilisk-checker/src/rules/directives_deprecated/visit_stmt.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_deprecated`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`directives_deprecated`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Statement visitor for `directives_deprecated`. //! //! Contains `visit_stmt_for_usage` and helpers for assignment-related diff --git a/crates/basilisk-checker/src/rules/directives_disjoint_base.rs b/crates/basilisk-checker/src/rules/directives_disjoint_base.rs index 313e6534..98ecfe4f 100644 --- a/crates/basilisk-checker/src/rules/directives_disjoint_base.rs +++ b/crates/basilisk-checker/src/rules/directives_disjoint_base.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_disjoint_base`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`directives_disjoint_base`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `directives_disjoint_base`: PEP 800 disjoint bases. //! //! PEP 800 introduces `typing.disjoint_base`. A class is a *disjoint base* when diff --git a/crates/basilisk-checker/src/rules/directives_reveal_type.rs b/crates/basilisk-checker/src/rules/directives_reveal_type.rs index 65901990..3486823c 100644 --- a/crates/basilisk-checker/src/rules/directives_reveal_type.rs +++ b/crates/basilisk-checker/src/rules/directives_reveal_type.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_reveal_type`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`directives_reveal_type`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `directives_reveal_type`: Invalid `reveal_type()` call. //! //! `reveal_type(expr)` must be called with exactly one positional argument. diff --git a/crates/basilisk-checker/src/rules/directives_version_platform.rs b/crates/basilisk-checker/src/rules/directives_version_platform.rs index 515862b2..9d15a6dc 100644 --- a/crates/basilisk-checker/src/rules/directives_version_platform.rs +++ b/crates/basilisk-checker/src/rules/directives_version_platform.rs @@ -1,4 +1,4 @@ -//! Implements [`directives_version_platform`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`directives_version_platform`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `directives_version_platform`: Variable defined only in dead version/platform branch. //! //! When `sys.version_info`, `sys.platform`, or `os.name` is compared against diff --git a/crates/basilisk-checker/src/rules/enums_behaviors.rs b/crates/basilisk-checker/src/rules/enums_behaviors.rs index 94cbbe47..e9238843 100644 --- a/crates/basilisk-checker/src/rules/enums_behaviors.rs +++ b/crates/basilisk-checker/src/rules/enums_behaviors.rs @@ -1,4 +1,4 @@ -//! Implements [`enums_behaviors`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`enums_behaviors`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! `enums_behaviors`: Invalid Enum subclassing. //! //! An Enum class with one or more defined members is implicitly final and diff --git a/crates/basilisk-checker/src/rules/enums_expansion.rs b/crates/basilisk-checker/src/rules/enums_expansion.rs index d8c914a2..e46f411b 100644 --- a/crates/basilisk-checker/src/rules/enums_expansion.rs +++ b/crates/basilisk-checker/src/rules/enums_expansion.rs @@ -1,4 +1,4 @@ -//! Implements [`enums_expansion`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-coercion +//! Implements [`enums_expansion`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-COERCION //! `enums_expansion`: `assert_type` with `Literal[Enum.MEMBER]` on enum-typed param. //! //! This rule detects when `assert_type()` is used with a `Literal[Enum.MEMBER]` type diff --git a/crates/basilisk-checker/src/rules/enums_member_access.rs b/crates/basilisk-checker/src/rules/enums_member_access.rs index 17afc521..90d2eeac 100644 --- a/crates/basilisk-checker/src/rules/enums_member_access.rs +++ b/crates/basilisk-checker/src/rules/enums_member_access.rs @@ -1,4 +1,4 @@ -//! Implements [`enums_definition`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`enums_definition`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `enums_definition`: access to an enum member that does not exist for the target. //! //! Enum members may be defined conditionally on a statically-known check such as diff --git a/crates/basilisk-checker/src/rules/enums_member_values.rs b/crates/basilisk-checker/src/rules/enums_member_values.rs index 681ef2e0..d75a5bc8 100644 --- a/crates/basilisk-checker/src/rules/enums_member_values.rs +++ b/crates/basilisk-checker/src/rules/enums_member_values.rs @@ -1,4 +1,4 @@ -//! Implements [`enums_member_values`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-coercion +//! Implements [`enums_member_values`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-COERCION //! `enums_member_values`: Enum member value incompatible with `_value_` type annotation. //! //! When an enum class declares `_value_: T` (annotation-only, no value), all diff --git a/crates/basilisk-checker/src/rules/enums_members.rs b/crates/basilisk-checker/src/rules/enums_members.rs index 59add0e9..347ef289 100644 --- a/crates/basilisk-checker/src/rules/enums_members.rs +++ b/crates/basilisk-checker/src/rules/enums_members.rs @@ -1,4 +1,4 @@ -//! Implements [`enums_members`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`enums_members`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! `enums_members`: Enum member annotated with an explicit type. //! //! In an Enum class, members should NOT carry explicit type annotations. diff --git a/crates/basilisk-checker/src/rules/enums_members_2.rs b/crates/basilisk-checker/src/rules/enums_members_2.rs index 97155e13..0b0ed7cb 100644 --- a/crates/basilisk-checker/src/rules/enums_members_2.rs +++ b/crates/basilisk-checker/src/rules/enums_members_2.rs @@ -1,4 +1,4 @@ -//! Implements [`enums_members_2`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-coercion +//! Implements [`enums_members_2`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-COERCION //! `enums_members_2`: Non-member referenced in `Literal[EnumClass.X]` annotation. //! //! The `Literal[EnumClass.X]` type is only valid when `X` is an actual enum diff --git a/crates/basilisk-checker/src/rules/explicit_any.rs b/crates/basilisk-checker/src/rules/explicit_any.rs index 00d2843b..98151a7d 100644 --- a/crates/basilisk-checker/src/rules/explicit_any.rs +++ b/crates/basilisk-checker/src/rules/explicit_any.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0014] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [BSK-0014] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! BSK-0014: Explicit `Any` annotation. //! //! Emitted as a `Warning` when a function parameter or return annotation is diff --git a/crates/basilisk-checker/src/rules/generics_base_class.rs b/crates/basilisk-checker/src/rules/generics_base_class.rs index 0cb5f375..e6f3f21b 100644 --- a/crates/basilisk-checker/src/rules/generics_base_class.rs +++ b/crates/basilisk-checker/src/rules/generics_base_class.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_base_class`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`generics_base_class`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `generics_base_class`: Duplicate `TypeVar` in a `Generic[...]` base. //! //! Each type parameter in `Generic[T1, T2, ...]` must be unique. diff --git a/crates/basilisk-checker/src/rules/generics_base_class_2.rs b/crates/basilisk-checker/src/rules/generics_base_class_2.rs index 199ee6c1..a6c6e432 100644 --- a/crates/basilisk-checker/src/rules/generics_base_class_2.rs +++ b/crates/basilisk-checker/src/rules/generics_base_class_2.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_base_class_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_base_class_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_base_class_2`: Inconsistent `TypeVar` ordering across base classes. //! //! When a class inherits from multiple generic bases that share a common diff --git a/crates/basilisk-checker/src/rules/generics_base_class_3.rs b/crates/basilisk-checker/src/rules/generics_base_class_3.rs index 0924eae0..90f5770f 100644 --- a/crates/basilisk-checker/src/rules/generics_base_class_3.rs +++ b/crates/basilisk-checker/src/rules/generics_base_class_3.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_base_class_3`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_base_class_3`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_base_class_3`: Invariant generic type mismatch at call site. //! //! When a function parameter expects a parameterised generic like diff --git a/crates/basilisk-checker/src/rules/generics_basic.rs b/crates/basilisk-checker/src/rules/generics_basic.rs index 69dec307..06aef6c6 100644 --- a/crates/basilisk-checker/src/rules/generics_basic.rs +++ b/crates/basilisk-checker/src/rules/generics_basic.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_basic`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`generics_basic`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `generics_basic`: `TypeVar` declared with exactly one constraint. //! //! PEP 484 requires a `TypeVar` to have either zero constraints (unconstrained) diff --git a/crates/basilisk-checker/src/rules/generics_basic_2.rs b/crates/basilisk-checker/src/rules/generics_basic_2.rs index 7eb382e7..0af615e4 100644 --- a/crates/basilisk-checker/src/rules/generics_basic_2.rs +++ b/crates/basilisk-checker/src/rules/generics_basic_2.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_basic_2`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`generics_basic_2`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! `generics_basic_2`: Non-TypeVar argument in `Generic[...]` or `Protocol[...]`. //! //! PEP 484 requires that all arguments to `Generic[...]` and `Protocol[...]` diff --git a/crates/basilisk-checker/src/rules/generics_basic_3/helpers.rs b/crates/basilisk-checker/src/rules/generics_basic_3/helpers.rs index ad66e595..2c7cbcce 100644 --- a/crates/basilisk-checker/src/rules/generics_basic_3/helpers.rs +++ b/crates/basilisk-checker/src/rules/generics_basic_3/helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_basic_3`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_basic_3`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! All internal types, parsing, and checking logic for `generics_basic_3`. use std::collections::HashMap; diff --git a/crates/basilisk-checker/src/rules/generics_defaults.rs b/crates/basilisk-checker/src/rules/generics_defaults.rs index 83ac3986..bcccc5d3 100644 --- a/crates/basilisk-checker/src/rules/generics_defaults.rs +++ b/crates/basilisk-checker/src/rules/generics_defaults.rs @@ -1,6 +1,6 @@ //! Implements [`generics_defaults`] from [CHKARCH-DIAG-OWNERSHIP] and //! [TYPEINF-GENERICS-DEFAULTS]. See -//! docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `generics_defaults`: Non-default `TypeVar` follows a default `TypeVar` in `Generic[...]`. //! //! PEP 696 §Ordering defines two ordering rules for type parameters in `Generic[...]`: diff --git a/crates/basilisk-checker/src/rules/generics_defaults_2.rs b/crates/basilisk-checker/src/rules/generics_defaults_2.rs index 79813daf..409e8bd4 100644 --- a/crates/basilisk-checker/src/rules/generics_defaults_2.rs +++ b/crates/basilisk-checker/src/rules/generics_defaults_2.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_defaults_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_defaults_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_defaults_2`: Incompatible `TypeVar` bound or constraint with its default. //! //! PEP 696 specifies two constraints on `TypeVar` defaults: diff --git a/crates/basilisk-checker/src/rules/generics_defaults_referential.rs b/crates/basilisk-checker/src/rules/generics_defaults_referential.rs index a4bdc4c9..9c2ec885 100644 --- a/crates/basilisk-checker/src/rules/generics_defaults_referential.rs +++ b/crates/basilisk-checker/src/rules/generics_defaults_referential.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_defaults_referential`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_defaults_referential`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_defaults_referential`: Invalid `TypeVar` default referencing another `TypeVar`. //! //! PEP 696 specifies constraints on `TypeVar` defaults that reference other `TypeVars`: diff --git a/crates/basilisk-checker/src/rules/generics_defaults_referential_2.rs b/crates/basilisk-checker/src/rules/generics_defaults_referential_2.rs index e80da98f..dd9a43c1 100644 --- a/crates/basilisk-checker/src/rules/generics_defaults_referential_2.rs +++ b/crates/basilisk-checker/src/rules/generics_defaults_referential_2.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_defaults_referential_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_defaults_referential_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_defaults_referential_2`: ```TypeVar``` default referential violations. //! //! PEP 696 defines rules for when a `TypeVar` default references another diff --git a/crates/basilisk-checker/src/rules/generics_defaults_referential_2_helpers.rs b/crates/basilisk-checker/src/rules/generics_defaults_referential_2_helpers.rs index 68b294fe..55f0c32a 100644 --- a/crates/basilisk-checker/src/rules/generics_defaults_referential_2_helpers.rs +++ b/crates/basilisk-checker/src/rules/generics_defaults_referential_2_helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_defaults_referential_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_defaults_referential_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Helper types and functions for `generics_defaults_referential_2`. //! //! Provides source-level parsing of `TypeVar` definitions, argument splitting, diff --git a/crates/basilisk-checker/src/rules/generics_defaults_specialization.rs b/crates/basilisk-checker/src/rules/generics_defaults_specialization.rs index 73b4408e..6ed910b9 100644 --- a/crates/basilisk-checker/src/rules/generics_defaults_specialization.rs +++ b/crates/basilisk-checker/src/rules/generics_defaults_specialization.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_defaults_specialization`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_defaults_specialization`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_defaults_specialization`: Wrong number of type arguments to a generic class or type alias. //! //! When a user-defined generic class has both required (non-default) and optional diff --git a/crates/basilisk-checker/src/rules/generics_scoping.rs b/crates/basilisk-checker/src/rules/generics_scoping.rs index 8ef18335..3fa40744 100644 --- a/crates/basilisk-checker/src/rules/generics_scoping.rs +++ b/crates/basilisk-checker/src/rules/generics_scoping.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_scoping`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_scoping`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_scoping`: Unbound type variable in scope. //! //! A type variable used in a type annotation must be "in scope" — i.e. it must diff --git a/crates/basilisk-checker/src/rules/generics_self_attributes.rs b/crates/basilisk-checker/src/rules/generics_self_attributes.rs index b37dece2..044b4b4f 100644 --- a/crates/basilisk-checker/src/rules/generics_self_attributes.rs +++ b/crates/basilisk-checker/src/rules/generics_self_attributes.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_self_attributes`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-optional +//! Implements [`generics_self_attributes`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OPTIONAL //! `generics_self_attributes`: Incompatible type for `Self`-typed attribute. //! //! When a class declares an attribute annotated with `Self` (or `Self | None`, diff --git a/crates/basilisk-checker/src/rules/generics_self_basic.rs b/crates/basilisk-checker/src/rules/generics_self_basic.rs index 80d46cd7..e2810a1a 100644 --- a/crates/basilisk-checker/src/rules/generics_self_basic.rs +++ b/crates/basilisk-checker/src/rules/generics_self_basic.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_self_basic`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-optional +//! Implements [`generics_self_basic`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OPTIONAL //! `generics_self_basic`: `Self` type violations in generics. //! //! This rule detects two kinds of `Self` type violations: diff --git a/crates/basilisk-checker/src/rules/generics_self_protocols.rs b/crates/basilisk-checker/src/rules/generics_self_protocols.rs index 0ba5ff69..3f05ecfc 100644 --- a/crates/basilisk-checker/src/rules/generics_self_protocols.rs +++ b/crates/basilisk-checker/src/rules/generics_self_protocols.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_self_protocols`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-optional +//! Implements [`generics_self_protocols`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OPTIONAL //! `generics_self_protocols`: Protocol `Self`-return conformance violation. //! //! When a `Protocol` declares a method returning `Self`, any class passed where diff --git a/crates/basilisk-checker/src/rules/generics_self_usage.rs b/crates/basilisk-checker/src/rules/generics_self_usage.rs index 242887bb..cf243c3f 100644 --- a/crates/basilisk-checker/src/rules/generics_self_usage.rs +++ b/crates/basilisk-checker/src/rules/generics_self_usage.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_self_usage`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_self_usage`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_self_usage`: `Self` type used in an invalid location. //! //! PEP 673 defines `Self` as a special type that refers to the current class. diff --git a/crates/basilisk-checker/src/rules/generics_syntax_compatibility.rs b/crates/basilisk-checker/src/rules/generics_syntax_compatibility.rs index f1a705b4..02107cb9 100644 --- a/crates/basilisk-checker/src/rules/generics_syntax_compatibility.rs +++ b/crates/basilisk-checker/src/rules/generics_syntax_compatibility.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_syntax_compatibility`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`generics_syntax_compatibility`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! `generics_syntax_compatibility`: PEP 695 type parameter syntax mixed with traditional `TypeVars`. //! //! PEP 695 introduced a new syntax for declaring type parameters (`class Foo[T]` diff --git a/crates/basilisk-checker/src/rules/generics_syntax_declarations.rs b/crates/basilisk-checker/src/rules/generics_syntax_declarations.rs index 6195bf80..93ee2131 100644 --- a/crates/basilisk-checker/src/rules/generics_syntax_declarations.rs +++ b/crates/basilisk-checker/src/rules/generics_syntax_declarations.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_syntax_declarations`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_syntax_declarations`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_syntax_declarations`: Invalid PEP 695 type parameter bound or constraint. //! //! PEP 695 introduced a new syntax for declaring type parameters in class and diff --git a/crates/basilisk-checker/src/rules/generics_syntax_declarations_2.rs b/crates/basilisk-checker/src/rules/generics_syntax_declarations_2.rs index 077aabd6..fa5f0378 100644 --- a/crates/basilisk-checker/src/rules/generics_syntax_declarations_2.rs +++ b/crates/basilisk-checker/src/rules/generics_syntax_declarations_2.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_syntax_declarations_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_syntax_declarations_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_syntax_declarations_2`: Invalid attribute access on bounded type variable. //! //! When a PEP 695 type parameter has a bound (e.g., `T: str`), attribute diff --git a/crates/basilisk-checker/src/rules/generics_syntax_scoping/alias_misuse.rs b/crates/basilisk-checker/src/rules/generics_syntax_scoping/alias_misuse.rs index ee528566..4a204b67 100644 --- a/crates/basilisk-checker/src/rules/generics_syntax_scoping/alias_misuse.rs +++ b/crates/basilisk-checker/src/rules/generics_syntax_scoping/alias_misuse.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_syntax_scoping`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_syntax_scoping`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! PEP 695 type-alias misuse (violation 7) and type-argument bound checks //! (violation 8) for `generics_syntax_scoping`. //! diff --git a/crates/basilisk-checker/src/rules/generics_syntax_scoping/violations.rs b/crates/basilisk-checker/src/rules/generics_syntax_scoping/violations.rs index e0cc39e7..7668ed2c 100644 --- a/crates/basilisk-checker/src/rules/generics_syntax_scoping/violations.rs +++ b/crates/basilisk-checker/src/rules/generics_syntax_scoping/violations.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_syntax_scoping`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_syntax_scoping`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! AST-driven PEP 695 scoping checks (violations 1-6) for `generics_syntax_scoping`. //! //! Every check consumes [`basilisk_resolver::Pep695Scoping`] — facts derived diff --git a/crates/basilisk-checker/src/rules/generics_type_erasure.rs b/crates/basilisk-checker/src/rules/generics_type_erasure.rs index 0d3b8a6b..5eb41207 100644 --- a/crates/basilisk-checker/src/rules/generics_type_erasure.rs +++ b/crates/basilisk-checker/src/rules/generics_type_erasure.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_type_erasure`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_type_erasure`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_type_erasure`: Access to instance attribute on a class object. //! //! Instance attributes (annotations without `ClassVar` in the class body that diff --git a/crates/basilisk-checker/src/rules/generics_typevartuple_args/mod.rs b/crates/basilisk-checker/src/rules/generics_typevartuple_args/mod.rs index 774cf2d4..e2864796 100644 --- a/crates/basilisk-checker/src/rules/generics_typevartuple_args/mod.rs +++ b/crates/basilisk-checker/src/rules/generics_typevartuple_args/mod.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_typevartuple_args`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_typevartuple_args`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_typevartuple_args`: `TypeVarTuple` argument count mismatch. //! //! When a constructor with `TypeVarTuple` parameters is called, the number of diff --git a/crates/basilisk-checker/src/rules/generics_typevartuple_args/star_args.rs b/crates/basilisk-checker/src/rules/generics_typevartuple_args/star_args.rs index bb946969..de5e2673 100644 --- a/crates/basilisk-checker/src/rules/generics_typevartuple_args/star_args.rs +++ b/crates/basilisk-checker/src/rules/generics_typevartuple_args/star_args.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_typevartuple_args`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_typevartuple_args`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Call-site validation for `*args` annotated with an unpacked tuple type //! (PEP 646): `*args: *tuple[int, ...]`, `*args: *tuple[int, str]`, and //! mixed forms like `*args: *tuple[int, *tuple[str, ...], str]`. diff --git a/crates/basilisk-checker/src/rules/generics_typevartuple_basic.rs b/crates/basilisk-checker/src/rules/generics_typevartuple_basic.rs index 7c6a9184..2bae769e 100644 --- a/crates/basilisk-checker/src/rules/generics_typevartuple_basic.rs +++ b/crates/basilisk-checker/src/rules/generics_typevartuple_basic.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_typevartuple_basic`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [`generics_typevartuple_basic`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! `generics_typevartuple_basic`: Invalid `TypeVar` / `TypeVarTuple` / `ParamSpec` keyword argument combination. //! //! PEP 484 / PEP 695 forbid certain combinations of keyword arguments in diff --git a/crates/basilisk-checker/src/rules/generics_typevartuple_basic_2.rs b/crates/basilisk-checker/src/rules/generics_typevartuple_basic_2.rs index 96c8524c..ce1b38b4 100644 --- a/crates/basilisk-checker/src/rules/generics_typevartuple_basic_2.rs +++ b/crates/basilisk-checker/src/rules/generics_typevartuple_basic_2.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_typevartuple_basic_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_typevartuple_basic_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_typevartuple_basic_2`: `TypeVarTuple` must be unpacked with `*` operator. //! //! When a `TypeVarTuple` is used in a generic class base list or as a direct diff --git a/crates/basilisk-checker/src/rules/generics_typevartuple_basic_3.rs b/crates/basilisk-checker/src/rules/generics_typevartuple_basic_3.rs index 960f7d99..8fbc11f7 100644 --- a/crates/basilisk-checker/src/rules/generics_typevartuple_basic_3.rs +++ b/crates/basilisk-checker/src/rules/generics_typevartuple_basic_3.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_typevartuple_basic_3`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_typevartuple_basic_3`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_typevartuple_basic_3`: `TypeVarTuple` variance/bounds/constraints violation. //! //! `TypeVarTuple` does not support specification of variance, bounds, or constraints. diff --git a/crates/basilisk-checker/src/rules/generics_typevartuple_callable.rs b/crates/basilisk-checker/src/rules/generics_typevartuple_callable.rs index 1d8416f6..05a8429c 100644 --- a/crates/basilisk-checker/src/rules/generics_typevartuple_callable.rs +++ b/crates/basilisk-checker/src/rules/generics_typevartuple_callable.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_typevartuple_callable`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_typevartuple_callable`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_typevartuple_callable`: `TypeVarTuple` callable/tuple argument mismatch. //! //! When a constructor (or function) links two parameters via a `TypeVarTuple` diff --git a/crates/basilisk-checker/src/rules/generics_typevartuple_specialization.rs b/crates/basilisk-checker/src/rules/generics_typevartuple_specialization.rs index dcc32650..de415995 100644 --- a/crates/basilisk-checker/src/rules/generics_typevartuple_specialization.rs +++ b/crates/basilisk-checker/src/rules/generics_typevartuple_specialization.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_typevartuple_specialization`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_typevartuple_specialization`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_typevartuple_specialization`: Multiple `TypeVarTuple` unpacks in generic or tuple type. //! //! Only a single `TypeVarTuple` unpack (`*Ts`) may appear in a type parameter diff --git a/crates/basilisk-checker/src/rules/generics_typevartuple_specialization_2.rs b/crates/basilisk-checker/src/rules/generics_typevartuple_specialization_2.rs index 20f55265..f9f734a4 100644 --- a/crates/basilisk-checker/src/rules/generics_typevartuple_specialization_2.rs +++ b/crates/basilisk-checker/src/rules/generics_typevartuple_specialization_2.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_typevartuple_specialization_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_typevartuple_specialization_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_typevartuple_specialization_2`: Invalid `TypeVarTuple` specialization of generic alias. //! //! Two related violations are detected: diff --git a/crates/basilisk-checker/src/rules/generics_typevartuple_unpack.rs b/crates/basilisk-checker/src/rules/generics_typevartuple_unpack.rs index f3c820ed..d305efb7 100644 --- a/crates/basilisk-checker/src/rules/generics_typevartuple_unpack.rs +++ b/crates/basilisk-checker/src/rules/generics_typevartuple_unpack.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_typevartuple_unpack`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_typevartuple_unpack`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_typevartuple_unpack`: `TypeVarTuple` unpack minimum type argument violation. //! //! When a function parameter has a type annotation containing a `TypeVarTuple` diff --git a/crates/basilisk-checker/src/rules/generics_upper_bound.rs b/crates/basilisk-checker/src/rules/generics_upper_bound.rs index 182a63ca..8c05aa95 100644 --- a/crates/basilisk-checker/src/rules/generics_upper_bound.rs +++ b/crates/basilisk-checker/src/rules/generics_upper_bound.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_upper_bound`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_upper_bound`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_upper_bound`: `TypeVar` upper bound violation at call site. //! //! When a function parameter is annotated with a `TypeVar` that has an upper diff --git a/crates/basilisk-checker/src/rules/generics_upper_bound_2.rs b/crates/basilisk-checker/src/rules/generics_upper_bound_2.rs index 70ddffd1..a5afa620 100644 --- a/crates/basilisk-checker/src/rules/generics_upper_bound_2.rs +++ b/crates/basilisk-checker/src/rules/generics_upper_bound_2.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_upper_bound_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_upper_bound_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_upper_bound_2`: `TypeVar` bound violation at call site. //! //! When a function has a parameter typed with a `TypeVar` that has a `bound`, diff --git a/crates/basilisk-checker/src/rules/generics_variance.rs b/crates/basilisk-checker/src/rules/generics_variance.rs index 55822c34..a64be4af 100644 --- a/crates/basilisk-checker/src/rules/generics_variance.rs +++ b/crates/basilisk-checker/src/rules/generics_variance.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_variance`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_variance`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `generics_variance`: Variance incompatibility in base class parameterisation. //! //! When a class inherits from a generic base class (directly or through a type diff --git a/crates/basilisk-checker/src/rules/generics_variance_inference/check.rs b/crates/basilisk-checker/src/rules/generics_variance_inference/check.rs index 4aae9161..cbee7cfa 100644 --- a/crates/basilisk-checker/src/rules/generics_variance_inference/check.rs +++ b/crates/basilisk-checker/src/rules/generics_variance_inference/check.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Method-call and constructor-call type checking for `generics_variance_inference`. use std::collections::{HashMap, HashSet}; diff --git a/crates/basilisk-checker/src/rules/generics_variance_inference/collect.rs b/crates/basilisk-checker/src/rules/generics_variance_inference/collect.rs index adec0f8c..eb3eabee 100644 --- a/crates/basilisk-checker/src/rules/generics_variance_inference/collect.rs +++ b/crates/basilisk-checker/src/rules/generics_variance_inference/collect.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Collection helpers for `generics_variance_inference`. use std::collections::HashMap; diff --git a/crates/basilisk-checker/src/rules/generics_variance_inference/types.rs b/crates/basilisk-checker/src/rules/generics_variance_inference/types.rs index 8b475a4d..84f4c8cb 100644 --- a/crates/basilisk-checker/src/rules/generics_variance_inference/types.rs +++ b/crates/basilisk-checker/src/rules/generics_variance_inference/types.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Data types for `generics_variance_inference`. use std::collections::{HashMap, HashSet}; diff --git a/crates/basilisk-checker/src/rules/generics_variance_inference/utils.rs b/crates/basilisk-checker/src/rules/generics_variance_inference/utils.rs index 1cb4b130..4ad19085 100644 --- a/crates/basilisk-checker/src/rules/generics_variance_inference/utils.rs +++ b/crates/basilisk-checker/src/rules/generics_variance_inference/utils.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Utility functions for `generics_variance_inference`. use crate::rules::shared::contains_typevar_reference; diff --git a/crates/basilisk-checker/src/rules/generics_variance_inference/variance.rs b/crates/basilisk-checker/src/rules/generics_variance_inference/variance.rs index 1625accb..0840238f 100644 --- a/crates/basilisk-checker/src/rules/generics_variance_inference/variance.rs +++ b/crates/basilisk-checker/src/rules/generics_variance_inference/variance.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Variance inference for `generics_variance_inference`. //! Implements [TYPEINF-GENERICS-VARIANCE]. //! diff --git a/crates/basilisk-checker/src/rules/generics_variance_inference/variance_check.rs b/crates/basilisk-checker/src/rules/generics_variance_inference/variance_check.rs index 975ec652..7e8b9c96 100644 --- a/crates/basilisk-checker/src/rules/generics_variance_inference/variance_check.rs +++ b/crates/basilisk-checker/src/rules/generics_variance_inference/variance_check.rs @@ -1,4 +1,4 @@ -//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`generics_variance_inference`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Variance assignment checking for `generics_variance_inference`. //! //! Checks module-level and function-body assignments for variance diff --git a/crates/basilisk-checker/src/rules/guards.rs b/crates/basilisk-checker/src/rules/guards.rs index ac46e560..33290c28 100644 --- a/crates/basilisk-checker/src/rules/guards.rs +++ b/crates/basilisk-checker/src/rules/guards.rs @@ -1,4 +1,4 @@ -//! Implements helpers for [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements helpers for [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Shared guard predicates used across multiple rules. //! //! These predicates identify Python typing patterns where strict annotation diff --git a/crates/basilisk-checker/src/rules/historical_positional.rs b/crates/basilisk-checker/src/rules/historical_positional.rs index d7e9adf1..f228923c 100644 --- a/crates/basilisk-checker/src/rules/historical_positional.rs +++ b/crates/basilisk-checker/src/rules/historical_positional.rs @@ -1,4 +1,4 @@ -//! Implements [`historical_positional`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-optional +//! Implements [`historical_positional`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OPTIONAL //! `historical_positional`: Historical positional-only parameter violations. //! //! Before PEP 570 (Python 3.8), the convention for marking parameters as diff --git a/crates/basilisk-checker/src/rules/imports_module_attribute/mod.rs b/crates/basilisk-checker/src/rules/imports_module_attribute/mod.rs index badaa075..44559a21 100644 --- a/crates/basilisk-checker/src/rules/imports_module_attribute/mod.rs +++ b/crates/basilisk-checker/src/rules/imports_module_attribute/mod.rs @@ -1,4 +1,4 @@ -//! Implements [`imports_module_attribute`] from [CHKARCH-DIAG] / [CHKARCH-DIAG-STUB-MEMBER]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-stub-member +//! Implements [`imports_module_attribute`] from [CHKARCH-DIAG] / [CHKARCH-DIAG-STUB-MEMBER]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STUB-MEMBER //! `imports_module_attribute`: Access to a module attribute the local stub does not declare. //! //! When `import X` resolves to an authoritative user/local or selected Typeshed diff --git a/crates/basilisk-checker/src/rules/imports_module_attribute/tests.rs b/crates/basilisk-checker/src/rules/imports_module_attribute/tests.rs index 4c475875..5a0db2fc 100644 --- a/crates/basilisk-checker/src/rules/imports_module_attribute/tests.rs +++ b/crates/basilisk-checker/src/rules/imports_module_attribute/tests.rs @@ -1,4 +1,4 @@ -//! Tests for [CHKARCH-DIAG-STUB-MEMBER]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-stub-member +//! Tests for [CHKARCH-DIAG-STUB-MEMBER]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STUB-MEMBER use std::collections::HashMap; use std::path::PathBuf; diff --git a/crates/basilisk-checker/src/rules/imports_unresolved.rs b/crates/basilisk-checker/src/rules/imports_unresolved.rs index 5e36c347..230fcb98 100644 --- a/crates/basilisk-checker/src/rules/imports_unresolved.rs +++ b/crates/basilisk-checker/src/rules/imports_unresolved.rs @@ -1,4 +1,4 @@ -//! Implements [`imports_unresolved`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`imports_unresolved`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `imports_unresolved`: Unresolved import. //! //! Fires when an import cannot be resolved and the module is not part of the diff --git a/crates/basilisk-checker/src/rules/lambda_missing_annotations.rs b/crates/basilisk-checker/src/rules/lambda_missing_annotations.rs index 634b9781..7692579f 100644 --- a/crates/basilisk-checker/src/rules/lambda_missing_annotations.rs +++ b/crates/basilisk-checker/src/rules/lambda_missing_annotations.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0040] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [BSK-0040] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! BSK-0040: Lambda function missing type annotations. //! //! Emitted when a lambda function is assigned to a variable without type annotations. diff --git a/crates/basilisk-checker/src/rules/literals_literalstring.rs b/crates/basilisk-checker/src/rules/literals_literalstring.rs index 065217cb..e4c2e487 100644 --- a/crates/basilisk-checker/src/rules/literals_literalstring.rs +++ b/crates/basilisk-checker/src/rules/literals_literalstring.rs @@ -1,4 +1,4 @@ -//! Implements [`literals_literalstring`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`literals_literalstring`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `literals_literalstring`: `LiteralString` and `Literal` assignment incompatibilities. //! //! Detects annotated local variables inside function bodies where the declared diff --git a/crates/basilisk-checker/src/rules/literals_literalstring_helpers.rs b/crates/basilisk-checker/src/rules/literals_literalstring_helpers.rs index efd085c0..9bf51c44 100644 --- a/crates/basilisk-checker/src/rules/literals_literalstring_helpers.rs +++ b/crates/basilisk-checker/src/rules/literals_literalstring_helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`literals_literalstring`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`literals_literalstring`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Helper functions for `literals_literalstring`: `LiteralString` and `Literal` assignment //! incompatibilities. //! diff --git a/crates/basilisk-checker/src/rules/literals_parameterizations.rs b/crates/basilisk-checker/src/rules/literals_parameterizations.rs index 59f5b44e..1f8047fc 100644 --- a/crates/basilisk-checker/src/rules/literals_parameterizations.rs +++ b/crates/basilisk-checker/src/rules/literals_parameterizations.rs @@ -1,4 +1,4 @@ -//! Implements [`literals_parameterizations`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [`literals_parameterizations`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! `literals_parameterizations`: Invalid `Literal` parameterization. //! //! PEP 586 restricts what values may appear inside `Literal[...]`. diff --git a/crates/basilisk-checker/src/rules/literals_parameterizations_2.rs b/crates/basilisk-checker/src/rules/literals_parameterizations_2.rs index 1f1fa945..71f8d483 100644 --- a/crates/basilisk-checker/src/rules/literals_parameterizations_2.rs +++ b/crates/basilisk-checker/src/rules/literals_parameterizations_2.rs @@ -1,4 +1,4 @@ -//! Implements [`literals_parameterizations_2`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-coercion +//! Implements [`literals_parameterizations_2`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-COERCION //! `literals_parameterizations_2`: `Literal["EnumClass.MEMBER"]` (string) used where //! `Literal[EnumClass.MEMBER]` (enum member reference) is required. //! diff --git a/crates/basilisk-checker/src/rules/literals_semantics.rs b/crates/basilisk-checker/src/rules/literals_semantics.rs index ce4a31d6..06701235 100644 --- a/crates/basilisk-checker/src/rules/literals_semantics.rs +++ b/crates/basilisk-checker/src/rules/literals_semantics.rs @@ -1,4 +1,4 @@ -//! Implements [`literals_semantics`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`literals_semantics`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `literals_semantics`: Augmented assignment widens `Literal` type. //! //! Implements the supported annotated slice of [TYPEINF-VARS-AUGMENTED]: diff --git a/crates/basilisk-checker/src/rules/literals_semantics_2.rs b/crates/basilisk-checker/src/rules/literals_semantics_2.rs index 295ac67e..04c7938f 100644 --- a/crates/basilisk-checker/src/rules/literals_semantics_2.rs +++ b/crates/basilisk-checker/src/rules/literals_semantics_2.rs @@ -1,4 +1,4 @@ -//! Implements [`literals_semantics_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`literals_semantics_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `literals_semantics_2`: Literal value assignment incompatibility. //! //! Detects two classes of Literal-related assignment errors inside function bodies: diff --git a/crates/basilisk-checker/src/rules/match_exhaustiveness.rs b/crates/basilisk-checker/src/rules/match_exhaustiveness.rs index 62b4341b..f6ccfa80 100644 --- a/crates/basilisk-checker/src/rules/match_exhaustiveness.rs +++ b/crates/basilisk-checker/src/rules/match_exhaustiveness.rs @@ -1,4 +1,4 @@ -//! Implements [`match_exhaustiveness`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`match_exhaustiveness`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `match_exhaustiveness`: Non-exhaustive `match` statement. //! //! A value-dispatch `match` statement that has no irrefutable branch may fail diff --git a/crates/basilisk-checker/src/rules/missing_attribute_annotation.rs b/crates/basilisk-checker/src/rules/missing_attribute_annotation.rs index c5edbf5d..ce64c446 100644 --- a/crates/basilisk-checker/src/rules/missing_attribute_annotation.rs +++ b/crates/basilisk-checker/src/rules/missing_attribute_annotation.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0005] from [CHKARCH-DIAG-MISSING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-missing +//! Implements [BSK-0005] from [CHKARCH-DIAG-MISSING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-MISSING //! BSK-0005: Missing class attribute type annotation. //! //! Every class attribute declared in the class body must have an explicit type diff --git a/crates/basilisk-checker/src/rules/missing_override_decorator.rs b/crates/basilisk-checker/src/rules/missing_override_decorator.rs index 4eac8852..d1694896 100644 --- a/crates/basilisk-checker/src/rules/missing_override_decorator.rs +++ b/crates/basilisk-checker/src/rules/missing_override_decorator.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0025] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [BSK-0025] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! BSK-0025: Missing `@override` decorator. //! //! When a class overrides a method that is also defined in one of its base diff --git a/crates/basilisk-checker/src/rules/missing_parameter_annotation.rs b/crates/basilisk-checker/src/rules/missing_parameter_annotation.rs index c50953e0..6f214cd0 100644 --- a/crates/basilisk-checker/src/rules/missing_parameter_annotation.rs +++ b/crates/basilisk-checker/src/rules/missing_parameter_annotation.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0001] from [CHKARCH-DIAG-MISSING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-missing +//! Implements [BSK-0001] from [CHKARCH-DIAG-MISSING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-MISSING //! BSK-0001: Missing parameter type annotation. //! Implements the parameter-policy slice of [TYPEINF-REQUIRED] and the //! receiver exemption shared by [TYPEINF-SPECIAL-SELF]. diff --git a/crates/basilisk-checker/src/rules/missing_return_annotation.rs b/crates/basilisk-checker/src/rules/missing_return_annotation.rs index 0c64bb78..94548f23 100644 --- a/crates/basilisk-checker/src/rules/missing_return_annotation.rs +++ b/crates/basilisk-checker/src/rules/missing_return_annotation.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0002] from [CHKARCH-DIAG-MISSING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-missing +//! Implements [BSK-0002] from [CHKARCH-DIAG-MISSING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-MISSING //! BSK-0002: Missing return type annotation. //! //! Never fires where the current engine already infers the return type diff --git a/crates/basilisk-checker/src/rules/missing_type_stubs/mod.rs b/crates/basilisk-checker/src/rules/missing_type_stubs/mod.rs index 9b8acb85..034ed623 100644 --- a/crates/basilisk-checker/src/rules/missing_type_stubs/mod.rs +++ b/crates/basilisk-checker/src/rules/missing_type_stubs/mod.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0152] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [BSK-0152] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! BSK-0152: Missing type stubs for installed package. //! //! Fires when a package is imported and resolves to a `.py` source file (not diff --git a/crates/basilisk-checker/src/rules/missing_vararg_annotation.rs b/crates/basilisk-checker/src/rules/missing_vararg_annotation.rs index c138765b..d171b40d 100644 --- a/crates/basilisk-checker/src/rules/missing_vararg_annotation.rs +++ b/crates/basilisk-checker/src/rules/missing_vararg_annotation.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0004] from [CHKARCH-DIAG-MISSING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-missing +//! Implements [BSK-0004] from [CHKARCH-DIAG-MISSING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-MISSING //! BSK-0004: Missing `*args` / `**kwargs` type annotation. //! //! This house rule is off by default — the default configuration is pure PEP diff --git a/crates/basilisk-checker/src/rules/missing_variable_type.rs b/crates/basilisk-checker/src/rules/missing_variable_type.rs index 1c26d565..8b231260 100644 --- a/crates/basilisk-checker/src/rules/missing_variable_type.rs +++ b/crates/basilisk-checker/src/rules/missing_variable_type.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0003] from [CHKARCH-DIAG-MISSING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-missing +//! Implements [BSK-0003] from [CHKARCH-DIAG-MISSING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-MISSING //! BSK-0003: Missing variable type annotation. //! //! Fires when a module-level variable has no type annotation. This house rule diff --git a/crates/basilisk-checker/src/rules/namedtuples_define_class.rs b/crates/basilisk-checker/src/rules/namedtuples_define_class.rs index e4a87155..4552918c 100644 --- a/crates/basilisk-checker/src/rules/namedtuples_define_class.rs +++ b/crates/basilisk-checker/src/rules/namedtuples_define_class.rs @@ -1,4 +1,4 @@ -//! Implements [`namedtuples_define_class`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`namedtuples_define_class`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `namedtuples_define_class`: `NamedTuple` class definition errors. //! //! Detects several categories of `NamedTuple` definition errors: diff --git a/crates/basilisk-checker/src/rules/namedtuples_define_functional.rs b/crates/basilisk-checker/src/rules/namedtuples_define_functional.rs index ca8d5891..00c10bd1 100644 --- a/crates/basilisk-checker/src/rules/namedtuples_define_functional.rs +++ b/crates/basilisk-checker/src/rules/namedtuples_define_functional.rs @@ -1,4 +1,4 @@ -//! Implements [`namedtuples_define_functional`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-coercion +//! Implements [`namedtuples_define_functional`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-COERCION //! `namedtuples_define_functional`: Invalid argument in a `NamedTuple` constructor call. //! //! When a `NamedTuple` is instantiated using keyword arguments, Basilisk diff --git a/crates/basilisk-checker/src/rules/namedtuples_type_compat.rs b/crates/basilisk-checker/src/rules/namedtuples_type_compat.rs index 3ced3de8..13893203 100644 --- a/crates/basilisk-checker/src/rules/namedtuples_type_compat.rs +++ b/crates/basilisk-checker/src/rules/namedtuples_type_compat.rs @@ -1,4 +1,4 @@ -//! Implements [`namedtuples_type_compat`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-optional +//! Implements [`namedtuples_type_compat`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OPTIONAL //! `namedtuples_type_compat`: `NamedTuple`-to-tuple type incompatibility. //! //! When a `NamedTuple` instance is assigned to a variable annotated with a diff --git a/crates/basilisk-checker/src/rules/namedtuples_usage.rs b/crates/basilisk-checker/src/rules/namedtuples_usage.rs index abefe71b..2c6c4d62 100644 --- a/crates/basilisk-checker/src/rules/namedtuples_usage.rs +++ b/crates/basilisk-checker/src/rules/namedtuples_usage.rs @@ -1,4 +1,4 @@ -//! Implements [`namedtuples_usage`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`namedtuples_usage`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `namedtuples_usage`: `NamedTuple` usage violations. //! //! Detects invalid usage of `NamedTuple` instances: diff --git a/crates/basilisk-checker/src/rules/names_unbound.rs b/crates/basilisk-checker/src/rules/names_unbound.rs index 16f555c8..2c90ebb9 100644 --- a/crates/basilisk-checker/src/rules/names_unbound.rs +++ b/crates/basilisk-checker/src/rules/names_unbound.rs @@ -1,4 +1,4 @@ -//! Implements [`names_unbound`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`names_unbound`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `names_unbound`: Unbound variable on some code paths. //! //! When a function contains a `return ` statement and the name is diff --git a/crates/basilisk-checker/src/rules/names_undefined.rs b/crates/basilisk-checker/src/rules/names_undefined.rs index a785cf65..26797cbb 100644 --- a/crates/basilisk-checker/src/rules/names_undefined.rs +++ b/crates/basilisk-checker/src/rules/names_undefined.rs @@ -1,4 +1,4 @@ -//! Implements [`names_undefined`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`names_undefined`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `names_undefined`: Undefined variable used in a return statement. //! //! Flags any name referenced in a `return` expression — bare (`return x`), the diff --git a/crates/basilisk-checker/src/rules/narrowing_typeguard.rs b/crates/basilisk-checker/src/rules/narrowing_typeguard.rs index daca6ce5..f0f6fd1d 100644 --- a/crates/basilisk-checker/src/rules/narrowing_typeguard.rs +++ b/crates/basilisk-checker/src/rules/narrowing_typeguard.rs @@ -1,4 +1,4 @@ -//! Implements [`narrowing_typeguard`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`narrowing_typeguard`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `narrowing_typeguard`: `TypeGuard` or `TypeIs` on method with no narrowing parameter. //! //! The typing spec requires that a `TypeGuard` or `TypeIs` function must have diff --git a/crates/basilisk-checker/src/rules/narrowing_typeis.rs b/crates/basilisk-checker/src/rules/narrowing_typeis.rs index 096c3da7..fcff72e8 100644 --- a/crates/basilisk-checker/src/rules/narrowing_typeis.rs +++ b/crates/basilisk-checker/src/rules/narrowing_typeis.rs @@ -1,4 +1,4 @@ -//! Implements [`narrowing_typeis`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`narrowing_typeis`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `narrowing_typeis`: TypeGuard/TypeIs return type incompatibility in callable arguments. //! //! When a function returning `TypeGuard[X]` or `TypeIs[X]` is passed as an diff --git a/crates/basilisk-checker/src/rules/narrowing_typeis_2.rs b/crates/basilisk-checker/src/rules/narrowing_typeis_2.rs index 9fe971cc..46643c94 100644 --- a/crates/basilisk-checker/src/rules/narrowing_typeis_2.rs +++ b/crates/basilisk-checker/src/rules/narrowing_typeis_2.rs @@ -1,4 +1,4 @@ -//! Implements [`narrowing_typeis_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`narrowing_typeis_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `narrowing_typeis_2`: `TypeIs` narrows to a type inconsistent with the input type. //! //! Per the typing spec: "It is an error to narrow to a type that is not diff --git a/crates/basilisk-checker/src/rules/overloads_basic.rs b/crates/basilisk-checker/src/rules/overloads_basic.rs index f466afb7..b1066c3e 100644 --- a/crates/basilisk-checker/src/rules/overloads_basic.rs +++ b/crates/basilisk-checker/src/rules/overloads_basic.rs @@ -1,4 +1,4 @@ -//! Implements [`overloads_basic`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-optional +//! Implements [`overloads_basic`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OPTIONAL //! `overloads_basic`: No matching overload for subscript indexing. //! //! When a class defines overloaded `__getitem__` methods and a module-level diff --git a/crates/basilisk-checker/src/rules/overloads_consistency.rs b/crates/basilisk-checker/src/rules/overloads_consistency.rs index 90432b3a..3f29947f 100644 --- a/crates/basilisk-checker/src/rules/overloads_consistency.rs +++ b/crates/basilisk-checker/src/rules/overloads_consistency.rs @@ -1,4 +1,4 @@ -//! Implements [`overloads_consistency`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`overloads_consistency`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `overloads_consistency`: Overlapping `@overload` signatures. //! //! Within a group of `@overload` functions for the same name, every overload diff --git a/crates/basilisk-checker/src/rules/overloads_consistency_2.rs b/crates/basilisk-checker/src/rules/overloads_consistency_2.rs index 78fecf44..a50c6d2b 100644 --- a/crates/basilisk-checker/src/rules/overloads_consistency_2.rs +++ b/crates/basilisk-checker/src/rules/overloads_consistency_2.rs @@ -1,4 +1,4 @@ -//! Implements [`overloads_consistency_2`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`overloads_consistency_2`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `overloads_consistency_2`: Inconsistent decorators across an overloaded method. //! //! The typing spec constrains how decorators may be spread across an diff --git a/crates/basilisk-checker/src/rules/overloads_consistency_3.rs b/crates/basilisk-checker/src/rules/overloads_consistency_3.rs index 4b988c83..293a0f5f 100644 --- a/crates/basilisk-checker/src/rules/overloads_consistency_3.rs +++ b/crates/basilisk-checker/src/rules/overloads_consistency_3.rs @@ -1,4 +1,4 @@ -//! Implements [`overloads_consistency_3`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`overloads_consistency_3`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `overloads_consistency_3`: Overload implementation is inconsistent with its signatures. //! //! When an overload implementation is present the spec requires: diff --git a/crates/basilisk-checker/src/rules/overloads_definitions.rs b/crates/basilisk-checker/src/rules/overloads_definitions.rs index 3471ee4c..e8f5f86c 100644 --- a/crates/basilisk-checker/src/rules/overloads_definitions.rs +++ b/crates/basilisk-checker/src/rules/overloads_definitions.rs @@ -1,4 +1,4 @@ -//! Implements [`overloads_definitions`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`overloads_definitions`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `overloads_definitions`: Missing `@overload` implementation. //! //! When a function name is defined multiple times and every definition carries diff --git a/crates/basilisk-checker/src/rules/overloads_evaluation.rs b/crates/basilisk-checker/src/rules/overloads_evaluation.rs index 6ddfdbed..75764b7c 100644 --- a/crates/basilisk-checker/src/rules/overloads_evaluation.rs +++ b/crates/basilisk-checker/src/rules/overloads_evaluation.rs @@ -1,4 +1,4 @@ -//! Implements [`overloads_evaluation`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-optional +//! Implements [`overloads_evaluation`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OPTIONAL //! `overloads_evaluation`: Overload union expansion failure. //! //! When a function-body call passes a union-typed argument to an overloaded diff --git a/crates/basilisk-checker/src/rules/protocols_class_objects.rs b/crates/basilisk-checker/src/rules/protocols_class_objects.rs index 21e6c697..39e950a1 100644 --- a/crates/basilisk-checker/src/rules/protocols_class_objects.rs +++ b/crates/basilisk-checker/src/rules/protocols_class_objects.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_class_objects`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_class_objects`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_class_objects`: Protocol class used where `type[Proto]` is expected. //! //! The typing spec states: "Variables and parameters annotated with diff --git a/crates/basilisk-checker/src/rules/protocols_class_objects_2.rs b/crates/basilisk-checker/src/rules/protocols_class_objects_2.rs index 97d755be..10810225 100644 --- a/crates/basilisk-checker/src/rules/protocols_class_objects_2.rs +++ b/crates/basilisk-checker/src/rules/protocols_class_objects_2.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_class_objects_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_class_objects_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_class_objects_2`: Protocol class object violations. //! //! Detects two related violations involving Protocol classes and class objects: diff --git a/crates/basilisk-checker/src/rules/protocols_definition.rs b/crates/basilisk-checker/src/rules/protocols_definition.rs index b7fea1af..45dc565a 100644 --- a/crates/basilisk-checker/src/rules/protocols_definition.rs +++ b/crates/basilisk-checker/src/rules/protocols_definition.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_definition`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_definition`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_definition`: Protocol method sets self-attributes not declared in the Protocol. //! //! When a Protocol class defines a method (including `__init__`/`__new__`) that diff --git a/crates/basilisk-checker/src/rules/protocols_definition_2/ast_index.rs b/crates/basilisk-checker/src/rules/protocols_definition_2/ast_index.rs index 8422551c..3be85a1d 100644 --- a/crates/basilisk-checker/src/rules/protocols_definition_2/ast_index.rs +++ b/crates/basilisk-checker/src/rules/protocols_definition_2/ast_index.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_definition_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_definition_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! AST index for `protocols_definition_2` protocol conformance. //! //! E0121's structural checks need information the resolver's flattened diff --git a/crates/basilisk-checker/src/rules/protocols_definition_2/call_args.rs b/crates/basilisk-checker/src/rules/protocols_definition_2/call_args.rs index 19cb7d98..1484f1a7 100644 --- a/crates/basilisk-checker/src/rules/protocols_definition_2/call_args.rs +++ b/crates/basilisk-checker/src/rules/protocols_definition_2/call_args.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_definition_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_definition_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Protocol conformance for function-call arguments (`protocols_definition_2`). //! //! When a module-level function declares a parameter typed as an iterable of a diff --git a/crates/basilisk-checker/src/rules/protocols_definition_2/conformance.rs b/crates/basilisk-checker/src/rules/protocols_definition_2/conformance.rs index 7a8d803a..06d747e7 100644 --- a/crates/basilisk-checker/src/rules/protocols_definition_2/conformance.rs +++ b/crates/basilisk-checker/src/rules/protocols_definition_2/conformance.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_definition_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_definition_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Protocol member-kind conformance checks for `protocols_definition_2`. //! //! Beyond simple member-name presence, these checks verify that an diff --git a/crates/basilisk-checker/src/rules/protocols_definition_2/mod.rs b/crates/basilisk-checker/src/rules/protocols_definition_2/mod.rs index c3aceb56..3158b374 100644 --- a/crates/basilisk-checker/src/rules/protocols_definition_2/mod.rs +++ b/crates/basilisk-checker/src/rules/protocols_definition_2/mod.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_definition_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_definition_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_definition_2`: Protocol conformance violation in annotated assignment. //! //! Detects errors in annotated assignments at module level: diff --git a/crates/basilisk-checker/src/rules/protocols_explicit.rs b/crates/basilisk-checker/src/rules/protocols_explicit.rs index f37fd9aa..1f6198ae 100644 --- a/crates/basilisk-checker/src/rules/protocols_explicit.rs +++ b/crates/basilisk-checker/src/rules/protocols_explicit.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_explicit`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_explicit`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_explicit`: Direct instantiation of a Protocol class. //! //! Protocol classes define structural interfaces and cannot be instantiated diff --git a/crates/basilisk-checker/src/rules/protocols_explicit_2.rs b/crates/basilisk-checker/src/rules/protocols_explicit_2.rs index 8d1664db..12c70c8b 100644 --- a/crates/basilisk-checker/src/rules/protocols_explicit_2.rs +++ b/crates/basilisk-checker/src/rules/protocols_explicit_2.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_explicit_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_explicit_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_explicit_2`: Calling `super().method()` on an abstract method with no default //! implementation. //! diff --git a/crates/basilisk-checker/src/rules/protocols_explicit_3.rs b/crates/basilisk-checker/src/rules/protocols_explicit_3.rs index 02d4cb27..0266417a 100644 --- a/crates/basilisk-checker/src/rules/protocols_explicit_3.rs +++ b/crates/basilisk-checker/src/rules/protocols_explicit_3.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_explicit_3`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_explicit_3`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_explicit_3`: `super()` call on abstract protocol method with no default implementation. //! //! When a class explicitly implements a `Protocol` and one of its methods diff --git a/crates/basilisk-checker/src/rules/protocols_generic/helpers.rs b/crates/basilisk-checker/src/rules/protocols_generic/helpers.rs index 8f9ddf3a..e4e030f0 100644 --- a/crates/basilisk-checker/src/rules/protocols_generic/helpers.rs +++ b/crates/basilisk-checker/src/rules/protocols_generic/helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_generic`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_generic`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Helper functions for `protocols_generic`: Generic protocol violations. use std::collections::HashMap; diff --git a/crates/basilisk-checker/src/rules/protocols_merging.rs b/crates/basilisk-checker/src/rules/protocols_merging.rs index a66c7d2b..5c877442 100644 --- a/crates/basilisk-checker/src/rules/protocols_merging.rs +++ b/crates/basilisk-checker/src/rules/protocols_merging.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_merging`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_merging`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_merging`: Non-Protocol base class in a Protocol definition. //! //! Per PEP 544, a Protocol class may only inherit from other Protocol classes diff --git a/crates/basilisk-checker/src/rules/protocols_modules.rs b/crates/basilisk-checker/src/rules/protocols_modules.rs index c43af92c..491bd7a8 100644 --- a/crates/basilisk-checker/src/rules/protocols_modules.rs +++ b/crates/basilisk-checker/src/rules/protocols_modules.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_modules`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-optional +//! Implements [`protocols_modules`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OPTIONAL //! `protocols_modules`: Module assigned to incompatible protocol type. //! //! When a module object is assigned to a variable typed as a `Protocol`, the diff --git a/crates/basilisk-checker/src/rules/protocols_runtime_checkable.rs b/crates/basilisk-checker/src/rules/protocols_runtime_checkable.rs index 00e1a3e1..aec1c226 100644 --- a/crates/basilisk-checker/src/rules/protocols_runtime_checkable.rs +++ b/crates/basilisk-checker/src/rules/protocols_runtime_checkable.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_runtime_checkable`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_runtime_checkable`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_runtime_checkable`: Protocol `isinstance`/`issubclass` violations. //! //! Per PEP 544: diff --git a/crates/basilisk-checker/src/rules/protocols_runtime_checkable_2.rs b/crates/basilisk-checker/src/rules/protocols_runtime_checkable_2.rs index 51d83640..ae4d7401 100644 --- a/crates/basilisk-checker/src/rules/protocols_runtime_checkable_2.rs +++ b/crates/basilisk-checker/src/rules/protocols_runtime_checkable_2.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_runtime_checkable_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_runtime_checkable_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_runtime_checkable_2`: Protocol `isinstance`/`issubclass` violations. //! //! Per PEP 544: diff --git a/crates/basilisk-checker/src/rules/protocols_subtyping.rs b/crates/basilisk-checker/src/rules/protocols_subtyping.rs index 600ad6db..397d184c 100644 --- a/crates/basilisk-checker/src/rules/protocols_subtyping.rs +++ b/crates/basilisk-checker/src/rules/protocols_subtyping.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_subtyping`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_subtyping`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_subtyping`: Protocol attribute tuple element type mismatch. //! //! When a class explicitly implements a `Protocol` and assigns to a diff --git a/crates/basilisk-checker/src/rules/protocols_variance.rs b/crates/basilisk-checker/src/rules/protocols_variance.rs index a3cc3653..54de50ea 100644 --- a/crates/basilisk-checker/src/rules/protocols_variance.rs +++ b/crates/basilisk-checker/src/rules/protocols_variance.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_variance`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_variance`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_variance`: Protocol variance violation. //! //! Detects when a Protocol class declares `TypeVar`s with incorrect variance diff --git a/crates/basilisk-checker/src/rules/protocols_variance_2.rs b/crates/basilisk-checker/src/rules/protocols_variance_2.rs index a115f77e..f9077e7d 100644 --- a/crates/basilisk-checker/src/rules/protocols_variance_2.rs +++ b/crates/basilisk-checker/src/rules/protocols_variance_2.rs @@ -1,4 +1,4 @@ -//! Implements [`protocols_variance_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`protocols_variance_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `protocols_variance_2`: Protocol `TypeVar` variance mismatch. //! //! When a generic protocol class declares a `TypeVar` as invariant but the diff --git a/crates/basilisk-checker/src/rules/qualifiers_annotated/helpers.rs b/crates/basilisk-checker/src/rules/qualifiers_annotated/helpers.rs index 25ae1596..b963334e 100644 --- a/crates/basilisk-checker/src/rules/qualifiers_annotated/helpers.rs +++ b/crates/basilisk-checker/src/rules/qualifiers_annotated/helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`qualifiers_annotated`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`qualifiers_annotated`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! Helper functions for `qualifiers_annotated`: Invalid first argument to `Annotated[...]`. //! //! Contains annotation parsing utilities, type expression validity checks, diff --git a/crates/basilisk-checker/src/rules/qualifiers_annotated_2.rs b/crates/basilisk-checker/src/rules/qualifiers_annotated_2.rs index 5bfb3edb..012acc83 100644 --- a/crates/basilisk-checker/src/rules/qualifiers_annotated_2.rs +++ b/crates/basilisk-checker/src/rules/qualifiers_annotated_2.rs @@ -1,4 +1,4 @@ -//! Implements [`qualifiers_annotated_2`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [`qualifiers_annotated_2`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! `qualifiers_annotated_2`: `Annotated[...]` requires at least two arguments. //! //! PEP 593 requires `Annotated` to be subscripted with at least two arguments: diff --git a/crates/basilisk-checker/src/rules/qualifiers_final_annotation.rs b/crates/basilisk-checker/src/rules/qualifiers_final_annotation.rs index af7d7f2a..dd56277d 100644 --- a/crates/basilisk-checker/src/rules/qualifiers_final_annotation.rs +++ b/crates/basilisk-checker/src/rules/qualifiers_final_annotation.rs @@ -1,4 +1,4 @@ -//! Implements [`qualifiers_final_annotation`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`qualifiers_final_annotation`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! `qualifiers_final_annotation`: `Final` used in an invalid position. //! //! PEP 591 restricts `Final[T]` to: diff --git a/crates/basilisk-checker/src/rules/qualifiers_final_annotation_2.rs b/crates/basilisk-checker/src/rules/qualifiers_final_annotation_2.rs index 19cba1e3..d9b86876 100644 --- a/crates/basilisk-checker/src/rules/qualifiers_final_annotation_2.rs +++ b/crates/basilisk-checker/src/rules/qualifiers_final_annotation_2.rs @@ -1,4 +1,4 @@ -//! Implements [`qualifiers_final_annotation_2`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [`qualifiers_final_annotation_2`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! `qualifiers_final_annotation_2`: `Final` type qualifier annotation violations. //! //! Detects violations of PEP 591's rules for the `Final` qualifier, beyond the diff --git a/crates/basilisk-checker/src/rules/qualifiers_final_decorator.rs b/crates/basilisk-checker/src/rules/qualifiers_final_decorator.rs index e890ace8..0b8683e2 100644 --- a/crates/basilisk-checker/src/rules/qualifiers_final_decorator.rs +++ b/crates/basilisk-checker/src/rules/qualifiers_final_decorator.rs @@ -1,4 +1,4 @@ -//! Implements [`qualifiers_final_decorator`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`qualifiers_final_decorator`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `qualifiers_final_decorator`: `@final` decorator violations. //! //! Three violations are detected: diff --git a/crates/basilisk-checker/src/rules/redundant_annotation.rs b/crates/basilisk-checker/src/rules/redundant_annotation.rs index b315c7c7..0ffec5cd 100644 --- a/crates/basilisk-checker/src/rules/redundant_annotation.rs +++ b/crates/basilisk-checker/src/rules/redundant_annotation.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0050] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [BSK-0050] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! BSK-0050: Redundant type annotation warning. //! //! Emits a warning when a type annotation is redundant because the inferred type diff --git a/crates/basilisk-checker/src/rules/returns_compatibility.rs b/crates/basilisk-checker/src/rules/returns_compatibility.rs index 7aad4f1e..37d6d0fa 100644 --- a/crates/basilisk-checker/src/rules/returns_compatibility.rs +++ b/crates/basilisk-checker/src/rules/returns_compatibility.rs @@ -1,4 +1,4 @@ -//! Implements [`returns_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`returns_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `returns_compatibility`: Return type mismatch. //! //! Emitted as an `Error` when the literal value returned by a function is diff --git a/crates/basilisk-checker/src/rules/returns_compatibility_2.rs b/crates/basilisk-checker/src/rules/returns_compatibility_2.rs index 568f3177..72516fd6 100644 --- a/crates/basilisk-checker/src/rules/returns_compatibility_2.rs +++ b/crates/basilisk-checker/src/rules/returns_compatibility_2.rs @@ -1,4 +1,4 @@ -//! Implements [`returns_compatibility_2`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`returns_compatibility_2`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `returns_compatibility_2`: Return type mismatch — inferred return type incompatible with annotation. //! //! When a function has a return type annotation, the inferred return type must be diff --git a/crates/basilisk-checker/src/rules/shared.rs b/crates/basilisk-checker/src/rules/shared.rs index f6d0b296..b155ecca 100644 --- a/crates/basilisk-checker/src/rules/shared.rs +++ b/crates/basilisk-checker/src/rules/shared.rs @@ -1,4 +1,4 @@ -//! Implements helpers for [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements helpers for [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Shared helper functions used across multiple type checking rules. //! //! Consolidated from duplicated implementations in individual rule modules diff --git a/crates/basilisk-checker/src/rules/specialtypes_never.rs b/crates/basilisk-checker/src/rules/specialtypes_never.rs index ba99c51f..3c803be2 100644 --- a/crates/basilisk-checker/src/rules/specialtypes_never.rs +++ b/crates/basilisk-checker/src/rules/specialtypes_never.rs @@ -1,4 +1,4 @@ -//! Implements [`specialtypes_never`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-coercion +//! Implements [`specialtypes_never`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-COERCION //! `specialtypes_never`: `-> NoReturn` / `-> Never` function can fall through. //! //! A function declared with a return type of `NoReturn` or `Never` must diff --git a/crates/basilisk-checker/src/rules/specialtypes_never_2.rs b/crates/basilisk-checker/src/rules/specialtypes_never_2.rs index 8cc5d126..89e023c2 100644 --- a/crates/basilisk-checker/src/rules/specialtypes_never_2.rs +++ b/crates/basilisk-checker/src/rules/specialtypes_never_2.rs @@ -1,4 +1,4 @@ -//! Implements [`specialtypes_never_2`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-optional +//! Implements [`specialtypes_never_2`] from [CHKARCH-DIAG-OPTIONAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OPTIONAL //! `specialtypes_never_2`: `Never` type compatibility violations. //! //! Detects type compatibility errors involving the `Never` bottom type: diff --git a/crates/basilisk-checker/src/rules/specialtypes_promotions.rs b/crates/basilisk-checker/src/rules/specialtypes_promotions.rs index ac4e8b83..c2fd94b2 100644 --- a/crates/basilisk-checker/src/rules/specialtypes_promotions.rs +++ b/crates/basilisk-checker/src/rules/specialtypes_promotions.rs @@ -1,4 +1,4 @@ -//! Implements [`specialtypes_promotions`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-coercion +//! Implements [`specialtypes_promotions`] from [CHKARCH-DIAG-COERCION]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-COERCION //! `specialtypes_promotions`: Access to an `int`-only attribute on a `float`-typed parameter. //! //! The Python typing spec (PEP 484 / typing spec §Special cases for float and complex) diff --git a/crates/basilisk-checker/src/rules/specialtypes_type/helpers.rs b/crates/basilisk-checker/src/rules/specialtypes_type/helpers.rs index 70e3448b..ddf153d9 100644 --- a/crates/basilisk-checker/src/rules/specialtypes_type/helpers.rs +++ b/crates/basilisk-checker/src/rules/specialtypes_type/helpers.rs @@ -1,4 +1,4 @@ -//! Implements [`specialtypes_type`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`specialtypes_type`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! AST utility helpers and predicate functions for `specialtypes_type`. use ruff_python_ast::Expr; diff --git a/crates/basilisk-checker/src/rules/stale_lock_file.rs b/crates/basilisk-checker/src/rules/stale_lock_file.rs index c1f6b829..5e708a08 100644 --- a/crates/basilisk-checker/src/rules/stale_lock_file.rs +++ b/crates/basilisk-checker/src/rules/stale_lock_file.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0013] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [BSK-0013] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! BSK-0013: Stale uv lock file. //! //! Fires when the `uv.lock` file is older than `pyproject.toml`, indicating diff --git a/crates/basilisk-checker/src/rules/tuples_index.rs b/crates/basilisk-checker/src/rules/tuples_index.rs index f2bd2303..5f5e399e 100644 --- a/crates/basilisk-checker/src/rules/tuples_index.rs +++ b/crates/basilisk-checker/src/rules/tuples_index.rs @@ -1,4 +1,4 @@ -//! Implements [`tuples_index`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`tuples_index`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `tuples_index`: Tuple index out of bounds. //! //! When a fixed-length `tuple[T1, T2, ...]` variable is indexed with a literal diff --git a/crates/basilisk-checker/src/rules/tuples_index_2.rs b/crates/basilisk-checker/src/rules/tuples_index_2.rs index 8161865c..642116bf 100644 --- a/crates/basilisk-checker/src/rules/tuples_index_2.rs +++ b/crates/basilisk-checker/src/rules/tuples_index_2.rs @@ -1,4 +1,4 @@ -//! Implements [`tuples_index_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`tuples_index_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `tuples_index_2`: Tuple index out of range. //! //! Detects subscript access on a fixed-length `tuple[T1, T2, ...]` parameter diff --git a/crates/basilisk-checker/src/rules/tuples_type_compat/annotation.rs b/crates/basilisk-checker/src/rules/tuples_type_compat/annotation.rs index 0473ca30..048bd354 100644 --- a/crates/basilisk-checker/src/rules/tuples_type_compat/annotation.rs +++ b/crates/basilisk-checker/src/rules/tuples_type_compat/annotation.rs @@ -1,4 +1,4 @@ -//! Implements [`tuples_type_compat`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`tuples_type_compat`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Tuple annotation parsing and compatibility helpers for `tuples_type_compat`. use crate::rules::shared::split_top_level_commas; diff --git a/crates/basilisk-checker/src/rules/tuples_type_compat/source.rs b/crates/basilisk-checker/src/rules/tuples_type_compat/source.rs index c9ed0b93..3b34c716 100644 --- a/crates/basilisk-checker/src/rules/tuples_type_compat/source.rs +++ b/crates/basilisk-checker/src/rules/tuples_type_compat/source.rs @@ -1,4 +1,4 @@ -//! Implements [`tuples_type_compat`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`tuples_type_compat`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! Source text parsing helpers for `tuples_type_compat`. use basilisk_resolver::Span; diff --git a/crates/basilisk-checker/src/rules/tuples_type_form.rs b/crates/basilisk-checker/src/rules/tuples_type_form.rs index ac6ae221..08805b3a 100644 --- a/crates/basilisk-checker/src/rules/tuples_type_form.rs +++ b/crates/basilisk-checker/src/rules/tuples_type_form.rs @@ -1,4 +1,4 @@ -//! Implements [`tuples_type_form`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-immutability +//! Implements [`tuples_type_form`] from [CHKARCH-DIAG-IMMUTABILITY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-IMMUTABILITY //! `tuples_type_form`: Multiple unbounded tuple components in a single tuple type. //! //! A `tuple[...]` type annotation may contain at most one unbounded component. diff --git a/crates/basilisk-checker/src/rules/tuples_type_form_2.rs b/crates/basilisk-checker/src/rules/tuples_type_form_2.rs index 46c88449..425e0601 100644 --- a/crates/basilisk-checker/src/rules/tuples_type_form_2.rs +++ b/crates/basilisk-checker/src/rules/tuples_type_form_2.rs @@ -1,4 +1,4 @@ -//! Implements [`tuples_type_form_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`tuples_type_form_2`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `tuples_type_form_2`: Invalid tuple type syntax. //! //! Validates tuple type annotations according to PEP 646 rules: diff --git a/crates/basilisk-checker/src/rules/typeddicts_alt_syntax.rs b/crates/basilisk-checker/src/rules/typeddicts_alt_syntax.rs index 5aa88d3a..c2a52def 100644 --- a/crates/basilisk-checker/src/rules/typeddicts_alt_syntax.rs +++ b/crates/basilisk-checker/src/rules/typeddicts_alt_syntax.rs @@ -1,4 +1,4 @@ -//! Implements [`typeddicts_alt_syntax`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`typeddicts_alt_syntax`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `typeddicts_alt_syntax`: Invalid `TypedDict(...)` functional-syntax call. //! //! The `TypedDict(name, {...})` functional syntax has several constraints: diff --git a/crates/basilisk-checker/src/rules/typeddicts_class_syntax.rs b/crates/basilisk-checker/src/rules/typeddicts_class_syntax.rs index ca61b0b3..278729c9 100644 --- a/crates/basilisk-checker/src/rules/typeddicts_class_syntax.rs +++ b/crates/basilisk-checker/src/rules/typeddicts_class_syntax.rs @@ -1,4 +1,4 @@ -//! Implements [`typeddicts_class_syntax`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [`typeddicts_class_syntax`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! `typeddicts_class_syntax`: Method defined inside a `TypedDict` class. //! //! `TypedDict` classes (PEP 589) are restricted to key declarations only. diff --git a/crates/basilisk-checker/src/rules/typeddicts_class_syntax_2.rs b/crates/basilisk-checker/src/rules/typeddicts_class_syntax_2.rs index e4b7344b..8bd0ff54 100644 --- a/crates/basilisk-checker/src/rules/typeddicts_class_syntax_2.rs +++ b/crates/basilisk-checker/src/rules/typeddicts_class_syntax_2.rs @@ -1,4 +1,4 @@ -//! Implements [`typeddicts_class_syntax_2`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`typeddicts_class_syntax_2`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `typeddicts_class_syntax_2`: Invalid keyword argument in `TypedDict` class definition. //! //! `TypedDict` class syntax only accepts `total=True/False` as a keyword argument. diff --git a/crates/basilisk-checker/src/rules/typeddicts_operations/type_consistency.rs b/crates/basilisk-checker/src/rules/typeddicts_operations/type_consistency.rs index 4214712f..e4c9c124 100644 --- a/crates/basilisk-checker/src/rules/typeddicts_operations/type_consistency.rs +++ b/crates/basilisk-checker/src/rules/typeddicts_operations/type_consistency.rs @@ -1,4 +1,4 @@ -//! Implements [`typeddicts_operations`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`typeddicts_operations`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `TypedDict` type consistency checks (PEP 589 §5). //! //! Validates assignments where the RHS is a `TypedDict`-typed variable: diff --git a/crates/basilisk-checker/src/rules/typeddicts_readonly.rs b/crates/basilisk-checker/src/rules/typeddicts_readonly.rs index d06400e9..70f52774 100644 --- a/crates/basilisk-checker/src/rules/typeddicts_readonly.rs +++ b/crates/basilisk-checker/src/rules/typeddicts_readonly.rs @@ -1,4 +1,4 @@ -//! Implements [`typeddicts_readonly`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-structural +//! Implements [`typeddicts_readonly`] from [CHKARCH-DIAG-STRUCTURAL]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-STRUCTURAL //! `typeddicts_readonly`: Mutation of `ReadOnly` `TypedDict` fields //! //! Fields marked as `ReadOnly` in `TypedDict`s cannot be mutated through: diff --git a/crates/basilisk-checker/src/rules/typeddicts_required.rs b/crates/basilisk-checker/src/rules/typeddicts_required.rs index 38f774c1..eda3ae7c 100644 --- a/crates/basilisk-checker/src/rules/typeddicts_required.rs +++ b/crates/basilisk-checker/src/rules/typeddicts_required.rs @@ -1,4 +1,4 @@ -//! Implements [`typeddicts_required`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Implements [`typeddicts_required`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP //! `typeddicts_required`: `Required` / `NotRequired` used in an invalid context. //! //! PEP 655 and the typing spec restrict `Required[T]` and `NotRequired[T]` to: diff --git a/crates/basilisk-checker/src/rules/typeddicts_usage.rs b/crates/basilisk-checker/src/rules/typeddicts_usage.rs index f4ccf464..00b9cdb5 100644 --- a/crates/basilisk-checker/src/rules/typeddicts_usage.rs +++ b/crates/basilisk-checker/src/rules/typeddicts_usage.rs @@ -1,4 +1,4 @@ -//! Implements [`typeddicts_usage`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag +//! Implements [`typeddicts_usage`] from [CHKARCH-DIAG]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG //! `typeddicts_usage`: `TypedDict` runtime violation. //! //! PEP 589 defines constraints on what you can do with `TypedDict` type objects at runtime: diff --git a/crates/basilisk-checker/src/rules/undeclared_dependency_import.rs b/crates/basilisk-checker/src/rules/undeclared_dependency_import.rs index 083b3b87..97f539b0 100644 --- a/crates/basilisk-checker/src/rules/undeclared_dependency_import.rs +++ b/crates/basilisk-checker/src/rules/undeclared_dependency_import.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0011] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [BSK-0011] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! BSK-0011: Undeclared dependency import. //! //! Fires when an import resolves to a package that is only a transitive diff --git a/crates/basilisk-checker/src/rules/unused_dependency.rs b/crates/basilisk-checker/src/rules/unused_dependency.rs index 5155149f..583d7648 100644 --- a/crates/basilisk-checker/src/rules/unused_dependency.rs +++ b/crates/basilisk-checker/src/rules/unused_dependency.rs @@ -1,4 +1,4 @@ -//! Implements [BSK-0012] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Implements [BSK-0012] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! BSK-0012: Unused dependency. //! //! Fires when a package is declared in `[project.dependencies]` but no module diff --git a/crates/basilisk-checker/src/span_util.rs b/crates/basilisk-checker/src/span_util.rs index 910a9e71..6cbe4643 100644 --- a/crates/basilisk-checker/src/span_util.rs +++ b/crates/basilisk-checker/src/span_util.rs @@ -1,4 +1,4 @@ -//! Implements [CHKARCH-DIAG-PHILOSOPHY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-philosophy +//! Implements [CHKARCH-DIAG-PHILOSOPHY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-PHILOSOPHY //! Safe source-text slicing helpers for [`basilisk_resolver::Span`]. /// Extract the source text covered by a [`basilisk_resolver::Span`]. diff --git a/crates/basilisk-checker/src/types.rs b/crates/basilisk-checker/src/types.rs index fe708645..7c8f1d26 100644 --- a/crates/basilisk-checker/src/types.rs +++ b/crates/basilisk-checker/src/types.rs @@ -1,6 +1,6 @@ //! Implements [TYPEINF-OVERVIEW], [TYPEINF-SUBTYPING], and //! [TYPEINF-SPECIAL]. See -//! docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md#typeinf-overview +//! docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md#TYPEINF-OVERVIEW //! Type representation for Basilisk's type inference engine. //! //! Annotation parsing logic lives in [`super::types_parsing`]. diff --git a/crates/basilisk-checker/src/types_parsing.rs b/crates/basilisk-checker/src/types_parsing.rs index 0bd8e88f..f381c679 100644 --- a/crates/basilisk-checker/src/types_parsing.rs +++ b/crates/basilisk-checker/src/types_parsing.rs @@ -1,4 +1,4 @@ -//! Implements [TYPEINF-OVERVIEW]. See docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md#typeinf-overview +//! Implements [TYPEINF-OVERVIEW]. See docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md#TYPEINF-OVERVIEW //! Annotation parsing for [`InferredType`]. //! //! Converts Python annotation text (e.g. `"list[int]"`, `"Callable[[str], bool]"`) diff --git a/crates/basilisk-checker/src/types_star_tuples.rs b/crates/basilisk-checker/src/types_star_tuples.rs index ba621b6a..c375928d 100644 --- a/crates/basilisk-checker/src/types_star_tuples.rs +++ b/crates/basilisk-checker/src/types_star_tuples.rs @@ -1,4 +1,4 @@ -//! Implements [TYPEINF-COLLECTIONS-TUPLES]. See docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md#typeinf-collections +//! Implements [TYPEINF-COLLECTIONS-TUPLES]. See docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md#TYPEINF-COLLECTIONS //! Homogeneous (`tuple[X, ...]`) and PEP 646 unpacked (`*tuple[...]`/`*Ts`) //! tuple matching, used by [`InferredType::is_assignable_to`] //! ([TYPEINF-SUBTYPING-IMPL]). diff --git a/crates/basilisk-checker/tests/bound_signature_tests.rs b/crates/basilisk-checker/tests/bound_signature_tests.rs index 3f50e063..10915550 100644 --- a/crates/basilisk-checker/tests/bound_signature_tests.rs +++ b/crates/basilisk-checker/tests/bound_signature_tests.rs @@ -95,12 +95,9 @@ fn pyi_mutation_flows_through_no_hand_table() { selection: basilisk_stubs::typeshed::source::SourceSelection::Custom { path: root.path().to_string_lossy().into_owned(), }, - verify_content: true, - use_cache: false, - url_template: None, + store_path: None, }; - let snapshot = basilisk_stubs::typeshed::runtime::production_manager(request, None) - .expect("custom manager") + let snapshot = basilisk_stubs::typeshed::runtime::production_manager(request) .snapshot() .expect("custom generation activates"); let resolved = resolve_with_snapshot("value = ''.join('wrong')\n", snapshot); diff --git a/crates/basilisk-checker/tests/cached_tests.rs b/crates/basilisk-checker/tests/cached_tests.rs index 6c62f45b..ebf9a00a 100644 --- a/crates/basilisk-checker/tests/cached_tests.rs +++ b/crates/basilisk-checker/tests/cached_tests.rs @@ -1,5 +1,5 @@ //! Tests for [CHKCACHE-DIAG] / [CHKCACHE-DIAG-INTERN]. -//! See docs/specs/CHECKER-CACHE-SPEC.md#chkcache-entry +//! See docs/specs/CHECKER-CACHE-SPEC.md#CHKCACHE-ENTRY #![allow(clippy::allow_attributes, clippy::unwrap_used, clippy::expect_used)] //! Crate-boundary tests for the serde-friendly diagnostic projection and the //! `&'static` code interner used on cache replay. diff --git a/crates/basilisk-checker/tests/checker/classes_override_3_tests.rs b/crates/basilisk-checker/tests/checker/classes_override_3_tests.rs index 67be642e..aa9cbf24 100644 --- a/crates/basilisk-checker/tests/checker/classes_override_3_tests.rs +++ b/crates/basilisk-checker/tests/checker/classes_override_3_tests.rs @@ -1,4 +1,4 @@ -//! Tests for [`classes_override_3`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Tests for [`classes_override_3`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP // Integration tests for classes_override_3: @override with no matching ancestor method. use super::common::*; diff --git a/crates/basilisk-checker/tests/checker/dataclasses_inheritance_tests.rs b/crates/basilisk-checker/tests/checker/dataclasses_inheritance_tests.rs index e7d859d1..0be1f91f 100644 --- a/crates/basilisk-checker/tests/checker/dataclasses_inheritance_tests.rs +++ b/crates/basilisk-checker/tests/checker/dataclasses_inheritance_tests.rs @@ -1,4 +1,4 @@ -//! Tests for [`dataclasses_inheritance`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Tests for [`dataclasses_inheritance`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP // Integration tests for dataclasses_inheritance: dataclass field without a default after one with a default. use super::common::*; diff --git a/crates/basilisk-checker/tests/checker/overloads_consistency_2_tests.rs b/crates/basilisk-checker/tests/checker/overloads_consistency_2_tests.rs index f85cfc12..e059a3e9 100644 --- a/crates/basilisk-checker/tests/checker/overloads_consistency_2_tests.rs +++ b/crates/basilisk-checker/tests/checker/overloads_consistency_2_tests.rs @@ -1,4 +1,4 @@ -//! Tests for [`overloads_consistency_2`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Tests for [`overloads_consistency_2`] from [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP // Integration tests for overloads_consistency_2: inconsistent decorators across an overload group. use super::common::*; diff --git a/crates/basilisk-checker/tests/checker/overloads_consistency_3_tests.rs b/crates/basilisk-checker/tests/checker/overloads_consistency_3_tests.rs index 316f2bc4..6bee7b12 100644 --- a/crates/basilisk-checker/tests/checker/overloads_consistency_3_tests.rs +++ b/crates/basilisk-checker/tests/checker/overloads_consistency_3_tests.rs @@ -1,4 +1,4 @@ -//! Tests for [`overloads_consistency_3`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Tests for [`overloads_consistency_3`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY // Integration tests for overloads_consistency_3: overload implementation inconsistent with its signatures. use super::common::*; diff --git a/crates/basilisk-checker/tests/fp_elimination_tests.rs b/crates/basilisk-checker/tests/fp_elimination_tests.rs index c0bcc347..cc7ab64b 100644 --- a/crates/basilisk-checker/tests/fp_elimination_tests.rs +++ b/crates/basilisk-checker/tests/fp_elimination_tests.rs @@ -1,4 +1,4 @@ -//! Tests for [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-typesafety +//! Tests for [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY //! Coarse e2e tests locking in the `assignment_compatibility` false-positive eliminations from //! the completed false-positive elimination campaign. //! diff --git a/crates/basilisk-checker/tests/import_apply_tests.rs b/crates/basilisk-checker/tests/import_apply_tests.rs index 5be65b0c..b823f645 100644 --- a/crates/basilisk-checker/tests/import_apply_tests.rs +++ b/crates/basilisk-checker/tests/import_apply_tests.rs @@ -20,9 +20,7 @@ use basilisk_resolver::scope::{ImportResolution, PackageDepKind, UnresolvedReaso use basilisk_stubs::typeshed::archive::{Archive, ArchiveEntry, ArchiveVfs}; use basilisk_stubs::typeshed::gittree::FileMode; use basilisk_stubs::typeshed::snapshot::Snapshot; -use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, Transport, TypeshedStatus, -}; +use basilisk_stubs::typeshed::source::{LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus}; use basilisk_uv::PackageRegistry; mod import_support; @@ -322,11 +320,8 @@ fn make_custom_typeshed(stdlib_files: &[(&str, &str)]) -> Arc { active_source: SourceKind::Custom, commit: None, tree: None, - transport: Transport::CustomPath, license_status: LicenseStatus::NotSupplied, license_reference: None, - provenance: Provenance::UserManaged, - signed_release: false, warnings: Vec::new(), }; let uri_identity = identity.uri_component(); diff --git a/crates/basilisk-checker/tests/import_support/mod.rs b/crates/basilisk-checker/tests/import_support/mod.rs index 7ab3399b..52d2ede4 100644 --- a/crates/basilisk-checker/tests/import_support/mod.rs +++ b/crates/basilisk-checker/tests/import_support/mod.rs @@ -17,9 +17,7 @@ use basilisk_resolver::scope::{ImportKind, ImportResolution}; use basilisk_stubs::typeshed::archive::{Archive, ArchiveEntry, ArchiveVfs}; use basilisk_stubs::typeshed::gittree::FileMode; use basilisk_stubs::typeshed::snapshot::Snapshot; -use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, Transport, TypeshedStatus, -}; +use basilisk_stubs::typeshed::source::{LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus}; static TEST_CTR: AtomicU64 = AtomicU64::new(0); @@ -89,11 +87,8 @@ pub fn custom_typeshed_snapshot(files: &[(&str, &str)]) -> ActiveTypeshed { active_source: SourceKind::Custom, commit: None, tree: None, - transport: Transport::CustomPath, license_status: LicenseStatus::NotSupplied, license_reference: None, - provenance: Provenance::UserManaged, - signed_release: false, warnings: Vec::new(), }; let uri_identity = identity.uri_component(); diff --git a/crates/basilisk-checker/tests/inference_all_tests.rs b/crates/basilisk-checker/tests/inference_all_tests.rs index 1d4674ab..7a5f6635 100644 --- a/crates/basilisk-checker/tests/inference_all_tests.rs +++ b/crates/basilisk-checker/tests/inference_all_tests.rs @@ -1,4 +1,4 @@ -//! External tests for [TYPEINF-SPEC], [TYPEINF-OVERVIEW], [TYPEINF-INFERRED], +//! External tests for [TYPEINF], [TYPEINF-OVERVIEW], [TYPEINF-INFERRED], //! [TYPEINF-ALGO], [TYPEINF-VARS], [TYPEINF-SUBTYPING], //! [TYPEINF-SUBTYPING-IMPL], [TYPEINF-SPECIAL], [TYPEINF-IMPL], //! [TYPEINF-EXCEEDS], [TYPEINF-EXCEEDS-NOUNKNOWN], and diff --git a/crates/basilisk-checker/tests/rule_tags_tests.rs b/crates/basilisk-checker/tests/rule_tags_tests.rs index aff11b4f..b4edf4f7 100644 --- a/crates/basilisk-checker/tests/rule_tags_tests.rs +++ b/crates/basilisk-checker/tests/rule_tags_tests.rs @@ -1,4 +1,4 @@ -//! Implements [CHKTAG-TESTS] from [CHKTAG]. See docs/specs/CHECKER-RULE-TAGGING-SPEC.md#chktag-tests +//! Implements [CHKTAG-TESTS] from [CHKTAG]. See docs/specs/CHECKER-RULE-TAGGING-SPEC.md#CHKTAG-TESTS //! //! Coarse e2e for the rule tagging system: every shipping rule code resolves to //! a valid, conflict-free tag set, and the user-facing invariants hold — diff --git a/crates/basilisk-checker/tests/typeshed_snapshot_integration_tests.rs b/crates/basilisk-checker/tests/typeshed_snapshot_integration_tests.rs index 12604700..1e110ec5 100644 --- a/crates/basilisk-checker/tests/typeshed_snapshot_integration_tests.rs +++ b/crates/basilisk-checker/tests/typeshed_snapshot_integration_tests.rs @@ -20,9 +20,7 @@ use basilisk_stubs::types::{StubTarget, StubTargetPlatform}; use basilisk_stubs::typeshed::archive::{Archive, ArchiveEntry, ArchiveVfs}; use basilisk_stubs::typeshed::gittree::FileMode; use basilisk_stubs::typeshed::snapshot::Snapshot; -use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, Transport, TypeshedStatus, -}; +use basilisk_stubs::typeshed::source::{LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus}; use basilisk_test_utils::EventDb; fn entry(path: &str, source: &str) -> ArchiveEntry { @@ -59,11 +57,8 @@ fn micropython_snapshot() -> Arc { active_source: SourceKind::Custom, commit: None, tree: None, - transport: Transport::CustomPath, license_status: LicenseStatus::NotSupplied, license_reference: None, - provenance: Provenance::UserManaged, - signed_release: false, warnings: Vec::new(), }; Arc::new( diff --git a/crates/basilisk-cli/Cargo.toml b/crates/basilisk-cli/Cargo.toml index d45be296..d5dab931 100644 --- a/crates/basilisk-cli/Cargo.toml +++ b/crates/basilisk-cli/Cargo.toml @@ -19,6 +19,9 @@ basilisk-common.workspace = true basilisk-db.workspace = true basilisk-uv.workspace = true basilisk-stubs.workspace = true +# The user-invoked download surface ([STUBRES-TYPESHED-DOWNLOAD]); reachable +# only from `basilisk typeshed download`, never from check/analyze. +basilisk-typeshed-fetch.workspace = true clap.workspace = true # Shipwright `--version` / `--version --json` contract emitter. shipwright = "0.10.0" @@ -35,6 +38,8 @@ tracing-subscriber.workspace = true [dev-dependencies] basilisk-test-utils = { workspace = true, features = ["checker"] } basilisk-stubs.workspace = true +# The offline fake-GitHub fixture for the `typeshed download` CLI tests. +basilisk-typeshed-fetch = { workspace = true, features = ["test-support"] } tempfile = "3" [build-dependencies] diff --git a/crates/basilisk-cli/README.md b/crates/basilisk-cli/README.md index 40610510..23424046 100644 --- a/crates/basilisk-cli/README.md +++ b/crates/basilisk-cli/README.md @@ -15,8 +15,8 @@ basilisk check src/ --output json # JSON output for tooling ## Key concepts - **Pipeline orchestration** — calls `basilisk-parser` → `basilisk-resolver` → `basilisk-checker` in sequence for each file. -- **Parallel file checking** — uses Rayon for work-stealing parallelism across files. -- **Exit codes** — `0` (clean), `1` (type errors found), `3` (internal error). +- **Analysis-sized stack** — every subcommand is dispatched through `basilisk_lsp::runtime::run_with_analysis_stack`, so the recursive resolver and checker cannot overflow the default main-thread stack on deeply nested expressions ([LSPARCH-ARCH-STACK](../../docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-ARCH-STACK)). Collected files are then checked in a single sequential pass on that thread. +- **Exit codes** — `0` (completed without error diagnostics), `1` (error diagnostics were found), `2` (invalid configuration), `3` (internal failure). See [CHKARCH-CLI-EXITCODES](../../docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI-EXITCODES). - **Output formats** — human-readable rustc-style (default) and JSON for editor/CI integration. ## Dependencies @@ -28,8 +28,8 @@ basilisk check src/ --output json # JSON output for tooling | `basilisk-checker` | Type checking | | `basilisk-config` | Configuration | | `basilisk-stubs` | Type stubs | +| `basilisk-lsp` | LSP server (`basilisk lsp`) and the analysis-stack runtime | | `clap` | CLI argument parsing | -| `rayon` | Parallel file processing | ## Status diff --git a/crates/basilisk-cli/src/cache_check.rs b/crates/basilisk-cli/src/cache_check.rs index b9055b2d..a762bf27 100644 --- a/crates/basilisk-cli/src/cache_check.rs +++ b/crates/basilisk-cli/src/cache_check.rs @@ -1,5 +1,5 @@ //! Implements [CHKCACHE-CLI] / [CHKCACHE-FINGERPRINT]. -//! See docs/specs/CHECKER-CACHE-SPEC.md#chkcache-cli +//! See docs/specs/CHECKER-CACHE-SPEC.md#CHKCACHE-CLI //! //! CLI glue for the opt-in result cache: turns the `--cache*` flags into a //! [`CacheContext`], wraps the per-file cold check with a lookup/store, and @@ -224,7 +224,7 @@ mod tests { use basilisk_stubs::typeshed::gittree::{FileMode, Oid}; use basilisk_stubs::typeshed::snapshot::Snapshot; use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, Transport, TypeshedStatus, + LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus, }; use super::{build_context, typeshed_snapshot_identity, CacheContext, CacheOptions}; @@ -238,23 +238,12 @@ mod tests { }, commit: identity.commit(), tree: identity.commit(), - transport: if matches!(identity, SourceIdentity::Custom { .. }) { - Transport::CustomPath - } else { - Transport::Codeload - }, license_status: if matches!(identity, SourceIdentity::Custom { .. }) { LicenseStatus::NotSupplied } else { LicenseStatus::Approved }, license_reference: None, - provenance: if matches!(identity, SourceIdentity::Custom { .. }) { - Provenance::UserManaged - } else { - Provenance::GithubTlsAttested - }, - signed_release: false, warnings: Vec::new(), }; let archive = Archive::new(vec![ diff --git a/crates/basilisk-cli/src/main.rs b/crates/basilisk-cli/src/main.rs index 956f0c1b..8c6c4977 100644 --- a/crates/basilisk-cli/src/main.rs +++ b/crates/basilisk-cli/src/main.rs @@ -18,9 +18,7 @@ use shipwright_manifest::{ExecutableKind, Language}; use tracing::error; use crate::output::{render_diagnostics, render_diagnostics_json, ColorMode, OutputFormat}; -use crate::pipeline::{ - collect_and_check_with_overrides, pluralise, DiagnosticScope, PipelineError, TypeshedOverrides, -}; +use crate::pipeline::{collect_and_check, pluralise, DiagnosticScope, PipelineError}; mod adopt; mod cache_check; @@ -31,6 +29,7 @@ mod mcp; mod output; mod pipeline; mod stubs; +mod typeshed_cli; #[cfg(test)] use stubs::{cache_stub, find_package_source, run as run_stubs, StubAction, StubGenModeArg}; @@ -80,19 +79,6 @@ struct CheckArgs { /// Print cache hit/miss counts to stderr after checking. #[arg(long)] cache_stats: bool, - #[command(flatten)] - typeshed: TypeshedArgs, -} - -#[derive(clap::Args, Default)] -struct TypeshedArgs { - /// Download and validate Typeshed afresh for this run, then discard it. - #[arg(long)] - no_typeshed_cache: bool, - /// Waive Git-tree content attestation for this run. Safety, shape, and - /// license gates still run and the source is reported as UNVERIFIED. - #[arg(long)] - no_typeshed_verification: bool, } // Implements [CHKARCH-CLI-COMMANDS]: the `check`/`analyze` core commands @@ -167,6 +153,13 @@ enum Command { #[arg(long, default_value = ".", value_name = "DIR")] workspace: std::path::PathBuf, }, + /// Manage the verified typeshed store. Downloading happens ONLY here (and + /// via the editor's Download buttons) — checking never downloads + /// ([STUBRES-TYPESHED-DOWNLOAD]). + Typeshed { + #[command(subcommand)] + action: typeshed_cli::TypeshedAction, + }, /// Manage type stubs for untyped packages. Stubs { #[command(subcommand)] @@ -269,7 +262,11 @@ fn main() -> ExitCode { Ok(code) => code, Err(err) => { error!(%err, "analysis thread failed"); - 1 + // 3 = internal failure ([CHKARCH-CLI-EXITCODES]). This path is + // the analysis thread failing to run at all, which is never a + // finding about the user's code — reporting 1 here would tell a + // CI consumer "error diagnostics were found" when none were. + 3 } }; ExitCode::from(exit_code) @@ -318,6 +315,7 @@ fn run_command(command: Command) -> u8 { 1 } }, + Command::Typeshed { action } => typeshed_cli::run(action), Command::Stubs { action } => stubs::run(action), Command::CreateStub(args) => stubs::run_create_stub(args), } @@ -339,16 +337,7 @@ fn run_scoped_check(args: &CheckArgs, scope: DiagnosticScope) -> u8 { stats: args.cache_stats, }; let mut stats = cache_check::CacheStats::default(); - let result = collect_and_check_with_overrides( - &args.paths, - &cache, - &mut stats, - scope, - TypeshedOverrides { - no_cache: args.typeshed.no_typeshed_cache, - no_verification: args.typeshed.no_typeshed_verification, - }, - ); + let result = collect_and_check(&args.paths, &cache, &mut stats, scope); if cache.stats { stats.report(); } @@ -443,25 +432,54 @@ mod tests { cache: false, cache_dir: None, cache_stats: false, - typeshed: TypeshedArgs::default(), } } + /// [STUBRES-TYPESHED-OFFLINE]: the retired one-run waiver flags are gone — + /// there is no cache to skip and no verification to switch off, so a + /// command using them must fail to parse rather than silently no-op. #[test] - fn check_cli_accepts_one_run_typeshed_cache_and_verification_overrides() { - let cli = Cli::try_parse_from([ + fn check_cli_rejects_retired_typeshed_waiver_flags() { + for flag in ["--no-typeshed-cache", "--no-typeshed-verification"] { + assert!( + Cli::try_parse_from(["basilisk", "check", "example.py", flag]).is_err(), + "retired flag must be rejected: {flag}" + ); + } + } + + /// [STUBRES-TYPESHED-DOWNLOAD]: the download surface parses — latest by + /// default, or one exact `--commit`. + #[test] + fn typeshed_download_cli_parses_latest_and_exact_forms() { + let latest = Cli::try_parse_from(["basilisk", "typeshed", "download"]) + .expect("download latest must parse"); + let Command::Typeshed { + action: typeshed_cli::TypeshedAction::Download { commit, .. }, + } = latest.command + else { + panic!("expected typeshed download"); + }; + assert!(commit.is_none(), "no --commit means latest"); + + let exact = Cli::try_parse_from([ "basilisk", - "check", - "example.py", - "--no-typeshed-cache", - "--no-typeshed-verification", + "typeshed", + "download", + "--commit", + "83c2518a9e6abbda0c44592c3483de459198f887", ]) - .expect("documented Typeshed flags must parse"); - let Command::Check { args } = cli.command else { - panic!("expected check command"); + .expect("download --commit must parse"); + let Command::Typeshed { + action: typeshed_cli::TypeshedAction::Download { commit, .. }, + } = exact.command + else { + panic!("expected typeshed download"); }; - assert!(args.typeshed.no_typeshed_cache); - assert!(args.typeshed.no_typeshed_verification); + assert_eq!( + commit.as_deref(), + Some("83c2518a9e6abbda0c44592c3483de459198f887") + ); } /// An isolated project that opts the annotation house rule in, holding @@ -825,7 +843,6 @@ mod tests { cache: false, cache_dir: None, cache_stats: false, - typeshed: TypeshedArgs::default(), }, }); let _ = std::fs::remove_dir_all(&dir); @@ -848,7 +865,6 @@ mod tests { cache: true, cache_dir: Some(cache_dir), cache_stats: true, - typeshed: TypeshedArgs::default(), }, }); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/basilisk-cli/src/mcp.rs b/crates/basilisk-cli/src/mcp.rs index c3691aec..11e22733 100644 --- a/crates/basilisk-cli/src/mcp.rs +++ b/crates/basilisk-cli/src/mcp.rs @@ -229,7 +229,7 @@ fn initialize_response(id: Value, params: Option<&Value>, lifecycle: &mut Lifecy "version": env!("CARGO_PKG_VERSION"), "description": "Read-only Basilisk service status" }, - "instructions": "Use basilisk_typeshed_status to inspect the active standard-library source and its provenance warnings." + "instructions": "Use basilisk_typeshed_status to inspect the active standard-library source and its status warnings." }), ) } @@ -239,7 +239,7 @@ fn tools_result() -> Value { "tools": [{ "name": STATUS_TOOL, "title": "Typeshed source status", - "description": "Return the active typeshed source, exact commit/tree identities, transport, licensing state, and ordered warnings.", + "description": "Return the active typeshed source, exact commit/tree identities, licensing state, and ordered warnings.", "inputSchema": { "type": "object", "additionalProperties": false @@ -249,7 +249,9 @@ fn tools_result() -> Value { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, - "openWorldHint": true + // Resolution is offline by construction [STUBRES-TYPESHED-OFFLINE]: + // status never contacts an upstream, so the tool is closed-world. + "openWorldHint": false }, "execution": { "taskSupport": "forbidden" } }] @@ -262,7 +264,7 @@ fn status_schema() -> Value { "properties": { "active_source": { "type": "string", - "enum": ["custom", "exact-commit", "latest", "bundled"] + "enum": ["custom", "exact-commit", "bundled"] }, "commit_identity": { "anyOf": [ @@ -276,20 +278,11 @@ fn status_schema() -> Value { { "type": "null" } ] }, - "transport": { - "type": "string", - "enum": ["custom-path", "embedded-zip", "codeload", "mirror"] - }, - "provenance": { - "type": "string", - "enum": ["github-tls-attested", "bundle-vetted", "unverified", "user-managed"] - }, "license_status": { "type": "string", "enum": ["approved", "changed", "not supplied"] }, "license_reference": { "type": ["string", "null"] }, - "signed_release": { "type": "boolean" }, "warnings": { "type": "array", "items": { @@ -304,8 +297,8 @@ fn status_schema() -> Value { } }, "required": [ - "active_source", "commit_identity", "tree_identity", "transport", - "provenance", "license_status", "license_reference", "signed_release", "warnings" + "active_source", "commit_identity", "tree_identity", + "license_status", "license_reference", "warnings" ], "additionalProperties": false }) @@ -377,31 +370,21 @@ fn error_response(id: Value, code: i64, message: &str) -> Value { fn status_for_workspace(workspace: &Path) -> Result { let config = basilisk_lsp::config::load_analysis_config(workspace); let request = basilisk_lsp::config::typeshed_request(&config)?; - let manager = - basilisk_stubs::typeshed::runtime::production_manager(request, config.typeshed_cache_path) - .map_err(|error| error.to_string())?; + let manager = basilisk_stubs::typeshed::runtime::production_manager(request); let status = manager.status().map_err(|error| error.to_string())?; Ok(status_document(&status)) } +/// The active source IS the trust story — custom = user-managed, bundled = +/// build-vetted, exact commit = attested at download and re-proven offline — +/// so there are no separate transport/provenance fields to drift out of sync +/// ([STUBRES-TYPESHED-WARN]). fn status_document(status: &basilisk_stubs::typeshed::source::TypeshedStatus) -> Value { - let transport = match status.transport { - basilisk_stubs::typeshed::source::Transport::CustomPath => "custom-path", - basilisk_stubs::typeshed::source::Transport::EmbeddedZip => "embedded-zip", - basilisk_stubs::typeshed::source::Transport::Codeload => "codeload", - basilisk_stubs::typeshed::source::Transport::Mirror => "mirror", - }; let license_status = match status.license_status { basilisk_stubs::typeshed::source::LicenseStatus::Approved => "approved", basilisk_stubs::typeshed::source::LicenseStatus::Changed => "changed", basilisk_stubs::typeshed::source::LicenseStatus::NotSupplied => "not supplied", }; - let provenance = match status.provenance { - basilisk_stubs::typeshed::source::Provenance::GithubTlsAttested => "github-tls-attested", - basilisk_stubs::typeshed::source::Provenance::Unverified => "unverified", - basilisk_stubs::typeshed::source::Provenance::BundleVetted => "bundle-vetted", - basilisk_stubs::typeshed::source::Provenance::UserManaged => "user-managed", - }; let warnings: Vec = status .warnings .iter() @@ -415,11 +398,8 @@ fn status_document(status: &basilisk_stubs::typeshed::source::TypeshedStatus) -> "active_source": status.active_source.as_str(), "commit_identity": commit.as_deref(), "tree_identity": tree.as_deref(), - "transport": transport, - "provenance": provenance, "license_status": license_status, "license_reference": status.license_reference.as_deref(), - "signed_release": status.signed_release, "warnings": warnings }) } diff --git a/crates/basilisk-cli/src/mcp/tests.rs b/crates/basilisk-cli/src/mcp/tests.rs index 7aa86145..c86e81fe 100644 --- a/crates/basilisk-cli/src/mcp/tests.rs +++ b/crates/basilisk-cli/src/mcp/tests.rs @@ -5,15 +5,12 @@ fn status() -> Value { "active_source": "bundled", "commit_identity": "0123456789012345678901234567890123456789", "tree_identity": "abcdefabcdefabcdefabcdefabcdefabcdefabcd", - "transport": "embedded-zip", - "provenance": "bundle-vetted", "license_status": "approved", "license_reference": "typeshed://LICENSE", - "signed_release": false, "warnings": [ - { "code": "UNPINNED", "message": "Pin current to make this reproducible" }, - { "code": "DOWNLOAD FAILED", "message": "Using bundled fallback" }, - { "code": "UNVERIFIED", "message": "Contents were not checked" } + { "code": "UNPINNED", "message": "Pin a commit to make this reproducible" }, + { "code": "LICENSE CHANGED", "message": "Basilisk update/review required" }, + { "code": "USER-MANAGED SOURCE", "message": "Folder supplies its own license" } ] }) } @@ -92,14 +89,14 @@ fn lifecycle_lists_and_calls_structured_status() -> Result<(), String> { .get(1) .and_then(|warning| warning.get("code")) .and_then(Value::as_str), - Some("DOWNLOAD FAILED") + Some("LICENSE CHANGED") ); assert_eq!( warnings .get(2) .and_then(|warning| warning.get("code")) .and_then(Value::as_str), - Some("UNVERIFIED") + Some("USER-MANAGED SOURCE") ); Ok(()) } @@ -127,15 +124,27 @@ fn tool_contract_declares_closed_output_and_honest_annotations() { ); assert_eq!( result - .pointer("/tools/0/outputSchema/properties/transport/enum/0") + .pointer("/tools/0/outputSchema/properties/active_source/enum/0") .and_then(Value::as_str), - Some("custom-path") + Some("custom") ); assert_eq!( result - .pointer("/tools/0/outputSchema/properties/signed_release/type") + .pointer("/tools/0/outputSchema/properties/license_status/enum/2") .and_then(Value::as_str), - Some("boolean") + Some("not supplied") + ); + assert!( + result + .pointer("/tools/0/outputSchema/properties/transport") + .is_none(), + "the closed envelope must not resurrect the removed transport field" + ); + assert!( + result + .pointer("/tools/0/outputSchema/properties/signed_release") + .is_none(), + "the closed envelope must not resurrect the removed signed_release field" ); assert_eq!( result @@ -147,7 +156,9 @@ fn tool_contract_declares_closed_output_and_honest_annotations() { result .pointer("/tools/0/annotations/openWorldHint") .and_then(Value::as_bool), - Some(true) + Some(false), + "status resolution is offline by construction [STUBRES-TYPESHED-OFFLINE] — \ + the tool must declare itself closed-world" ); } @@ -176,7 +187,7 @@ fn acquisition_failure_is_a_tool_error_without_partial_status() -> Result<(), St #[test] fn shared_custom_status_projects_to_the_closed_mcp_envelope() { use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceKind, StatusWarning, Transport, TypeshedStatus, + LicenseStatus, SourceKind, StatusWarning, TypeshedStatus, }; use basilisk_stubs::typeshed::warning::{TypeshedWarning, UnpinnedKind}; @@ -184,11 +195,8 @@ fn shared_custom_status_projects_to_the_closed_mcp_envelope() { active_source: SourceKind::Custom, commit: None, tree: None, - transport: Transport::CustomPath, license_status: LicenseStatus::NotSupplied, license_reference: None, - provenance: Provenance::UserManaged, - signed_release: false, warnings: StatusWarning::list(&[ TypeshedWarning::UserManaged, TypeshedWarning::Unpinned(UnpinnedKind::CustomFolder), @@ -199,21 +207,21 @@ fn shared_custom_status_projects_to_the_closed_mcp_envelope() { document.get("active_source").and_then(Value::as_str), Some("custom") ); - assert_eq!( - document.get("transport").and_then(Value::as_str), - Some("custom-path") - ); - assert_eq!( - document.get("signed_release").and_then(Value::as_bool), - Some(false) - ); assert_eq!( document.get("license_status").and_then(Value::as_str), Some("not supplied") ); - assert_eq!( - document.get("provenance").and_then(Value::as_str), - Some("user-managed") + assert!( + document.get("transport").is_none(), + "active_source IS the trust story — no transport field may reappear" + ); + assert!( + document.get("provenance").is_none(), + "active_source IS the trust story — no provenance field may reappear" + ); + assert!( + document.get("signed_release").is_none(), + "active_source IS the trust story — no signed_release field may reappear" ); assert!(document.pointer("/warnings/0/severity").is_none()); assert_eq!( @@ -276,6 +284,118 @@ fn negotiation_and_notification_order_follow_lifecycle() -> Result<(), String> { Ok(()) } +/// [MCP-STDIO]: every malformed request shape gets the prescribed JSON-RPC +/// error — nothing is silently dropped and nothing kills the session. +#[test] +fn malformed_request_shapes_each_get_the_prescribed_error() -> Result<(), String> { + let responses = exchange(&[ + json!([1, 2, 3]), + json!({"jsonrpc":"2.0","id":true,"method":"ping"}), + json!({"jsonrpc":"2.0","id":4}), + json!({"jsonrpc":"1.0","id":5,"method":"ping"}), + ])?; + let codes: Vec> = responses + .iter() + .map(|response| response.pointer("/error/code").and_then(Value::as_i64)) + .collect(); + assert_eq!(codes, vec![Some(-32600); 4]); + Ok(()) +} + +/// [MCP-STDIO]: lifecycle guards — initialize without a protocol version, +/// re-initialize, unknown methods, unknown tools, and ping. +#[test] +fn lifecycle_guards_cover_reinit_unknown_methods_and_bad_tools() -> Result<(), String> { + let responses = exchange(&[ + json!({"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}), + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"t","version":"1"}}}), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + json!({"jsonrpc":"2.0","id":2,"method":"ping"}), + json!({"jsonrpc":"2.0","id":3,"method":"resources/list"}), + json!({"jsonrpc":"2.0","id":4,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION}}), + json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"unknown_tool","arguments":{}}}), + ])?; + let codes: Vec> = responses + .iter() + .map(|response| response.pointer("/error/code").and_then(Value::as_i64)) + .collect(); + assert_eq!( + codes, + vec![ + Some(-32602), + None, + None, + Some(-32601), + Some(-32600), + Some(-32602) + ] + ); + assert_eq!( + responses + .get(2) + .and_then(|response| response.pointer("/result")), + Some(&json!({})), + "ping must answer with an empty result" + ); + Ok(()) +} + +/// [MCP-STDIO]: a line that is not UTF-8 is a parse error, and the session +/// keeps serving afterwards. +#[test] +fn invalid_utf8_input_is_a_parse_error_and_the_session_survives() -> Result<(), String> { + let mut input: Vec = vec![0xFF, 0xFE, b'\n']; + input.extend_from_slice( + br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}"#, + ); + let mut output = Vec::new(); + run_transport(std::io::Cursor::new(input), &mut output, || Ok(status()))?; + let responses = String::from_utf8(output) + .map_err(|error| error.to_string())? + .lines() + .map(|line| serde_json::from_str::(line).map_err(|error| error.to_string())) + .collect::, _>>()?; + assert_eq!( + responses + .first() + .and_then(|response| response.pointer("/error/code")) + .and_then(Value::as_i64), + Some(-32700) + ); + assert!( + responses + .get(1) + .and_then(|response| response.pointer("/result/protocolVersion")) + .is_some(), + "the session must keep serving after a non-UTF-8 line" + ); + Ok(()) +} + +/// [STUBRES-TYPESHED-WARN]: every license state projects to its wire word. +#[test] +fn status_document_maps_every_license_state() { + use basilisk_stubs::typeshed::source::{LicenseStatus, SourceKind, TypeshedStatus}; + for (state, expected) in [ + (LicenseStatus::Approved, "approved"), + (LicenseStatus::Changed, "changed"), + (LicenseStatus::NotSupplied, "not supplied"), + ] { + let document = status_document(&TypeshedStatus { + active_source: SourceKind::Bundled, + commit: None, + tree: None, + license_status: state, + license_reference: None, + warnings: Vec::new(), + }); + assert_eq!( + document.get("license_status").and_then(Value::as_str), + Some(expected) + ); + } +} + #[test] fn oversized_line_is_drained_before_the_next_request() -> Result<(), String> { let mut input = vec![b' '; MAX_MESSAGE_BYTES + 1]; diff --git a/crates/basilisk-cli/src/pipeline/mod.rs b/crates/basilisk-cli/src/pipeline/mod.rs index 085be8dc..83eed5df 100644 --- a/crates/basilisk-cli/src/pipeline/mod.rs +++ b/crates/basilisk-cli/src/pipeline/mod.rs @@ -144,31 +144,7 @@ pub(crate) fn collect_and_check( stats: &mut cache_check::CacheStats, scope: DiagnosticScope, ) -> Result { - collect_and_check_with_overrides(paths, cache, stats, scope, TypeshedOverrides::default()) -} - -/// One-run command-line overrides for Typeshed acquisition policy. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub(crate) struct TypeshedOverrides { - pub(crate) no_cache: bool, - pub(crate) no_verification: bool, -} - -pub(crate) fn collect_and_check_with_overrides( - paths: &[String], - cache: &cache_check::CacheOptions, - stats: &mut cache_check::CacheStats, - scope: DiagnosticScope, - overrides: TypeshedOverrides, -) -> Result { - collect_and_check_with_typeshed( - paths, - cache, - stats, - scope, - overrides, - activate_production_typeshed, - ) + collect_and_check_with_typeshed(paths, cache, stats, scope, activate_production_typeshed) } fn collect_and_check_with_typeshed( @@ -176,7 +152,6 @@ fn collect_and_check_with_typeshed( cache: &cache_check::CacheOptions, stats: &mut cache_check::CacheStats, scope: DiagnosticScope, - overrides: TypeshedOverrides, activate_typeshed: F, ) -> Result where @@ -203,7 +178,7 @@ where basilisk_uv::python_version::resolve_target_python_version(&project_root); } - let mut workspace_config = + let workspace_config = load_cli_workspace_config(&project_root, config.python_version.as_deref()); if config.python_platform.is_none() { config @@ -222,12 +197,6 @@ where // pyproject.toml, uv.lock, and .venv live at the discovered project root, // not necessarily in the checked path. let roots = analysis_roots(paths, &project_root); - if overrides.no_cache { - workspace_config.typeshed_cache = false; - } - if overrides.no_verification { - workspace_config.typeshed_verify = false; - } let mut search_paths = if crate::import_search::files_might_import(&python_files) { build_import_search_paths_with_config(roots, &workspace_config) } else { diff --git a/crates/basilisk-cli/src/pipeline/tests.rs b/crates/basilisk-cli/src/pipeline/tests.rs index b56d480d..feb324af 100644 --- a/crates/basilisk-cli/src/pipeline/tests.rs +++ b/crates/basilisk-cli/src/pipeline/tests.rs @@ -31,7 +31,6 @@ fn collect_uncached( &no_cache(), &mut cache_check::CacheStats::default(), scope, - TypeshedOverrides::default(), activate_bundled_typeshed, ) } @@ -69,7 +68,6 @@ fn cli_activation_uses_custom_snapshot_and_target() -> Result<(), Box Result<(), Box Result<(), Box> { - let project = unique_project_dir("basilisk_cli_typeshed_overrides"); +fn a_missing_pin_tanks_the_check_instead_of_downloading() -> Result<(), Box> +{ + let project = unique_project_dir("basilisk_cli_typeshed_no_source"); std::fs::create_dir_all(&project)?; + let store = project.join("empty-store"); + std::fs::create_dir_all(&store)?; + // A valid full-SHA pin that no store on this machine holds. + let missing = "0123456789abcdef0123456789abcdef01234567"; std::fs::write( project.join("pyproject.toml"), - "[tool.basilisk]\ntypeshed-cache = true\ntypeshed-verify = true\n", + format!( + "[tool.basilisk]\ntypeshed-commit = \"{missing}\"\ntypeshed-store-path = \"{}\"\n", + store.display() + ), )?; let source = project.join("clean.py"); std::fs::write(&source, "value: int = 1\n")?; @@ -121,21 +130,22 @@ fn one_run_typeshed_overrides_reach_activation_without_mutating_config( &no_cache(), &mut cache_check::CacheStats::default(), DiagnosticScope::Check, - TypeshedOverrides { - no_cache: true, - no_verification: true, - }, - |_search_paths, config| { - assert!(!config.typeshed_cache); - assert!(!config.typeshed_verify); - Ok(()) - }, + super::typeshed::activate_production_typeshed, ); - let persisted = std::fs::read_to_string(project.join("pyproject.toml"))?; + // The store stays byte-for-byte inert: resolution never writes, repairs, + // or downloads ([STUBRES-TYPESHED-STORE]). + let store_entries = std::fs::read_dir(&store)?.count(); let _ = std::fs::remove_dir_all(project); - assert!(result.is_ok()); - assert!(persisted.contains("typeshed-cache = true")); - assert!(persisted.contains("typeshed-verify = true")); + let Err(PipelineError::Internal(message)) = result else { + return Err("a missing pin must tank the run".into()); + }; + assert!(message.contains("NO SOURCE"), "got: {message}"); + assert!(message.contains(missing), "got: {message}"); + assert!( + message.contains("basilisk typeshed download"), + "the failure must say how to materialise the pin: {message}" + ); + assert_eq!(store_entries, 0, "resolution must never write to the store"); Ok(()) } @@ -161,7 +171,6 @@ fn nested_file_inherits_project_python_target_for_analysis( &no_cache(), &mut cache_check::CacheStats::default(), DiagnosticScope::Check, - TypeshedOverrides::default(), |_search_paths, config| { assert_eq!(config.python_version.as_deref(), Some("3.12")); Ok(()) diff --git a/crates/basilisk-cli/src/pipeline/typeshed.rs b/crates/basilisk-cli/src/pipeline/typeshed.rs index bcfda188..b9f12427 100644 --- a/crates/basilisk-cli/src/pipeline/typeshed.rs +++ b/crates/basilisk-cli/src/pipeline/typeshed.rs @@ -53,19 +53,19 @@ pub(super) fn build_import_search_paths_with_config( search_paths } +/// Resolve the configured typeshed source — a local read, never a download +/// ([STUBRES-TYPESHED-OFFLINE]). A pin that is not on this machine is the +/// terminal `NO SOURCE` failure: analysis does not run, and the error itself +/// says how to materialise the pin (`basilisk typeshed download`). pub(super) fn activate_production_typeshed( search_paths: &mut basilisk_lsp::import_resolver::ImportSearchPaths, config: &basilisk_lsp::config::WorkspaceConfig, ) -> Result<(), PipelineError> { let request = basilisk_lsp::config::typeshed_request(config).map_err(PipelineError::Config)?; - let manager = basilisk_stubs::typeshed::runtime::production_manager( - request, - config.typeshed_cache_path.clone(), - ) - .map_err(|error| PipelineError::Config(error.to_string()))?; - let snapshot = manager.snapshot().map_err(|error| { - PipelineError::Internal(format!("typeshed acquisition failed: {error}")) - })?; + let manager = basilisk_stubs::typeshed::runtime::production_manager(request); + let snapshot = manager + .snapshot() + .map_err(|error| PipelineError::Internal(error.to_string()))?; report_typeshed_status(&snapshot.status); search_paths.typeshed_snapshot = Some(basilisk_checker::imports::ActiveTypeshed::new( snapshot, @@ -89,11 +89,8 @@ fn report_typeshed_status(status: &basilisk_stubs::typeshed::source::TypeshedSta active_source = status.active_source.as_str(), commit_identity, tree_identity, - transport = ?status.transport, license_status = ?status.license_status, license_reference, - provenance = ?status.provenance, - signed_release = status.signed_release, "typeshed source status" ); for warning in &status.warnings { @@ -299,20 +296,15 @@ mod tests { } #[test] - fn latest_fallback_status_is_loud_and_ordered() -> Result<(), Box> { + fn composed_status_warnings_are_loud_and_ordered() -> Result<(), Box> { use basilisk_stubs::typeshed::source::StatusWarning; use basilisk_stubs::typeshed::warning::{TypeshedWarning, UnpinnedKind}; let mut status = basilisk_stubs::typeshed::bundle::bundled_snapshot()?.status; status.warnings = StatusWarning::list(&[ - TypeshedWarning::DownloadFailed { - bundled_sha: status - .commit - .ok_or("bundled status is missing its commit identity")? - .to_hex(), - }, - TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled), - TypeshedWarning::Unverified, + TypeshedWarning::LicenseChanged, + TypeshedWarning::UserManaged, + TypeshedWarning::Unpinned(UnpinnedKind::CustomFolder), ]); let capture = Capture::default(); let subscriber = tracing_subscriber::fmt() @@ -325,16 +317,15 @@ mod tests { let stderr = capture.text()?; assert!(stderr.contains("typeshed source status"), "{stderr}"); - assert!(stderr.contains("signed_release=false"), "{stderr}"); let unpinned = stderr.find("warning_code=\"UNPINNED\""); - let failed = stderr.find("warning_code=\"DOWNLOAD FAILED\""); - let unverified = stderr.find("warning_code=\"UNVERIFIED\""); + let user_managed = stderr.find("warning_code=\"USER-MANAGED SOURCE\""); + let license = stderr.find("warning_code=\"LICENSE CHANGED\""); assert!( unpinned - .zip(failed) - .zip(unverified) + .zip(user_managed) + .zip(license) .is_some_and(|((first, second), third)| first < second && second < third), - "Latest fallback warnings must remain loud and canonical: {stderr}" + "status warnings must stay loud and in canonical order: {stderr}" ); Ok(()) } diff --git a/crates/basilisk-cli/src/stubs.rs b/crates/basilisk-cli/src/stubs.rs index e29766b9..c7c868ea 100644 --- a/crates/basilisk-cli/src/stubs.rs +++ b/crates/basilisk-cli/src/stubs.rs @@ -297,16 +297,19 @@ pub(super) fn find_package_source(package: &str, python_path: &Path) -> Option

u8 { let project_root = crate::pipeline::find_project_root(Path::new(".")); - let cache_dir = project_root.join(generate::cache::DEFAULT_CACHE_DIR); + print_status(&project_root.join(generate::cache::DEFAULT_CACHE_DIR)) +} + +fn print_status(cache_dir: &Path) -> u8 { if !cache_dir.exists() { println!("No generated stubs found ({})", cache_dir.display()); return 0; } - let modules: Vec = walkdir::WalkDir::new(&cache_dir) + let modules: Vec = walkdir::WalkDir::new(cache_dir) .into_iter() .filter_map(Result::ok) .filter(|entry| entry.file_type().is_file()) - .filter_map(|entry| stub_module_name(entry.path(), &cache_dir)) + .filter_map(|entry| stub_module_name(entry.path(), cache_dir)) .collect(); for module in &modules { println!(" {} {module}", "✓".green()); @@ -334,3 +337,78 @@ fn stub_module_name(path: &Path, cache_dir: &Path) -> Option { .join(".") }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// [STUBRES-AUTOGEN]: `stubs status` succeeds whether or not anything was + /// ever generated — an empty project is a clean report, not an error. + #[test] + fn status_reports_cleanly_with_and_without_generated_stubs( + ) -> Result<(), Box> { + let project = tempfile::tempdir()?; + let cache_dir = project.path().join(generate::cache::DEFAULT_CACHE_DIR); + assert_eq!(print_status(&cache_dir), 0, "missing cache dir is clean"); + + std::fs::create_dir_all(cache_dir.join("pkg"))?; + assert_eq!(print_status(&cache_dir), 0, "empty cache dir is clean"); + + std::fs::write(cache_dir.join("pkg").join("mod.pyi"), "x: int\n")?; + std::fs::write(cache_dir.join("notes.txt"), "not a stub\n")?; + assert_eq!(print_status(&cache_dir), 0, "generated stubs list cleanly"); + Ok(()) + } + + /// Stub paths render as dotted module names relative to the cache root; + /// non-`.pyi` files are not stubs. + #[test] + fn stub_module_names_are_dotted_and_cache_relative() { + let cache = Path::new("/cache"); + assert_eq!( + stub_module_name(Path::new("/cache/pkg/mod.pyi"), cache), + Some("pkg.mod".to_owned()) + ); + assert_eq!( + stub_module_name(Path::new("elsewhere/solo.pyi"), cache), + Some("elsewhere.solo".to_owned()) + ); + assert_eq!(stub_module_name(Path::new("/cache/notes.txt"), cache), None); + assert_eq!( + stub_module_name(Path::new("/cache/no_extension"), cache), + None + ); + } + /// The three CLI mode spellings map one-to-one onto generator modes. + #[test] + fn generation_mode_arguments_map_to_generator_modes() { + assert!(matches!( + StubGenMode::from(StubGenModeArg::Runtime), + StubGenMode::Runtime + )); + assert!(matches!( + StubGenMode::from(StubGenModeArg::Ast), + StubGenMode::Ast + )); + assert!(matches!( + StubGenMode::from(StubGenModeArg::Hybrid), + StubGenMode::Hybrid + )); + } + + /// [STUBRES-AUTOGEN]: contradictory or empty selections are rejected + /// before any interpreter or filesystem work happens. + #[test] + fn generation_target_selection_rejects_contradictory_requests() { + let root = Path::new("/nonexistent-project-root"); + let python = Path::new("python3"); + assert!( + generation_targets(&["requests".to_owned()], true, python, root).is_err(), + "--all plus explicit packages must be rejected" + ); + assert!( + generation_targets(&[], false, python, root).is_err(), + "no packages and no --all must be rejected" + ); + } +} diff --git a/crates/basilisk-cli/src/typeshed_cli.rs b/crates/basilisk-cli/src/typeshed_cli.rs new file mode 100644 index 00000000..3b67892b --- /dev/null +++ b/crates/basilisk-cli/src/typeshed_cli.rs @@ -0,0 +1,397 @@ +//! Implements the [STUBRES-TYPESHED-DOWNLOAD] CLI surface: +//! `basilisk typeshed download [--commit ]`. +//! +//! This command — like the editor's Download buttons — is the ONLY way +//! typeshed bytes arrive on a machine ([TYPESHEDRT-SEGREGATION]). `check` and +//! `analyze` never download: a pin that is not in the store tanks hard with +//! `NO SOURCE`, and this command is what that error tells the user to run. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use basilisk_typeshed_fetch::{DownloadPhase, GithubApi, GithubClient}; +use colored::Colorize as _; +use tracing::error; + +/// The `basilisk typeshed` action surface. +#[derive(Debug, clap::Subcommand)] +pub(crate) enum TypeshedAction { + /// Download and verify one typeshed commit into the content-addressed + /// store. With no `--commit` this resolves the latest + /// `python/typeshed@main` and writes the resolved SHA as the workspace's + /// `typeshed-commit` pin; with `--commit` it materialises that exact, + /// already-configured pin and writes no configuration. + Download { + /// Exact full 40-hex commit SHA to download (defaults to latest). + #[arg(long, value_name = "SHA")] + commit: Option, + /// Workspace whose configuration supplies the store location and, for + /// a latest download, receives the pin. + #[arg(long, default_value = ".", value_name = "DIR")] + workspace: PathBuf, + }, +} + +/// Dispatch a `basilisk typeshed` action. Returns the process exit code +/// ([CHKARCH-CLI-EXITCODES]: `0` ok, `2` invalid configuration, `3` failure). +pub(crate) fn run(action: TypeshedAction) -> u8 { + match action { + TypeshedAction::Download { commit, workspace } => run_download(commit, &workspace), + } +} + +fn run_download(commit: Option, workspace: &Path) -> u8 { + let client = GithubClient::new(); + download_action(commit, workspace, &client) +} + +/// The download action with its transport injected, so tests drive the whole +/// surface — config discovery, store resolution, progress — offline. +fn download_action(commit: Option, workspace: &Path, api: &dyn GithubApi) -> u8 { + let config = basilisk_lsp::config::load_analysis_config(workspace); + let store = config.typeshed_store_path; + let progress = |phase: DownloadPhase| println!(" {}", phase_label(phase).dimmed()); + match commit { + Some(sha) => download_exact(&sha, store, api, &progress), + None => download_latest_and_pin(workspace, store, api, &progress), + } +} + +fn download_exact( + sha: &str, + store: Option, + api: &dyn GithubApi, + progress: &dyn Fn(DownloadPhase), +) -> u8 { + let Ok(commit) = basilisk_stubs::typeshed::gittree::Oid::from_hex(sha) else { + error!( + len = sha.len(), + "--commit must be a full 40-character hex SHA" + ); + return 2; + }; + println!("Downloading typeshed {commit} into the verified store…"); + match basilisk_typeshed_fetch::download_commit(commit, store, api, progress) { + Ok(outcome) => { + println!( + "{} {} (tree {}) is now available offline", + "ok:".green().bold(), + outcome.commit, + outcome.tree + ); + 0 + } + Err(download_error) => { + error!(%download_error, "typeshed download failed; nothing was written"); + 3 + } + } +} + +fn download_latest_and_pin( + workspace: &Path, + store: Option, + api: &dyn GithubApi, + progress: &dyn Fn(DownloadPhase), +) -> u8 { + println!("Downloading the latest python/typeshed commit into the verified store…"); + let outcome = match basilisk_typeshed_fetch::download_latest(store, api, progress) { + Ok(outcome) => outcome, + Err(download_error) => { + error!(%download_error, "typeshed download failed; nothing was written"); + return 3; + } + }; + match write_pin(workspace, &outcome.commit.to_hex()) { + Ok(()) => { + println!( + "{} pinned typeshed-commit = {}", + "ok:".green().bold(), + outcome.commit + ); + 0 + } + Err(config_error) => { + // The store entry is verified and kept — only the pin write + // failed, so the command is re-runnable without a re-download. + error!(%config_error, commit = %outcome.commit, "downloaded but could not write the pin"); + 2 + } + } +} + +/// Write `typeshed-commit` through the same validated, structure-preserving +/// editor transaction the LSP configuration editor uses ([LSPCFGED-TYPESHED]). +/// +/// The pin and a custom folder are the two mutually exclusive step-3 sources +/// ([STUBRES-TYPESHED]), so the same transaction retires `typeshed-path` — +/// byte for byte the update the LSP's Download latest button writes +/// (`pin_update` in `crates/basilisk-lsp/src/typeshed_download.rs`). Without +/// the retirement the patch would name both sources and validation would +/// reject the whole write, leaving a downloaded commit unpinned. +fn write_pin(workspace: &Path, sha: &str) -> Result<(), basilisk_config::ConfigDocumentError> { + let document = basilisk_config::discover_config_document(workspace)?; + let update = basilisk_config::ConfigurationUpdate { + rules: basilisk_config::RuleConfigUpdate::default(), + typeshed: basilisk_config::TypeshedConfigUpdate { + entries: BTreeMap::from([ + ( + basilisk_config::TypeshedConfigKey::TypeshedCommit, + Some(sha.to_owned()), + ), + (basilisk_config::TypeshedConfigKey::TypeshedPath, None), + ]), + }, + }; + let patch = basilisk_config::build_configuration_patch(&document, &update)?; + basilisk_config::apply_config_patch(&patch) +} + +const fn phase_label(phase: DownloadPhase) -> &'static str { + match phase { + DownloadPhase::Resolving => "resolving commit metadata", + DownloadPhase::FetchingTree => "fetching the trusted file tree", + DownloadPhase::FetchingArchive => "downloading the archive", + DownloadPhase::Verifying => "verifying against the commit identity", + DownloadPhase::Writing => "writing the store entry", + } +} + +#[cfg(test)] +mod tests { + use basilisk_typeshed_fetch::testing::{fake_repo, FakeApi, Faults}; + + use super::*; + + /// [STUBRES-TYPESHED-DOWNLOAD]: the pin write is the same validated + /// editor transaction the configuration editor uses — structure + /// preserved, full SHA required. + #[test] + fn write_pin_round_trips_through_the_validated_editor() -> Result<(), Box> + { + let dir = tempfile::tempdir()?; + std::fs::write( + dir.path().join("pyproject.toml"), + "# keep\n[project]\nname = \"demo\"\n\n[tool.basilisk]\n", + )?; + write_pin(dir.path(), "83c2518a9e6abbda0c44592c3483de459198f887")?; + let written = std::fs::read_to_string(dir.path().join("pyproject.toml"))?; + assert!(written.contains("# keep")); + assert!( + written.contains("typeshed-commit = \"83c2518a9e6abbda0c44592c3483de459198f887\""), + "pin must be written: {written}" + ); + + assert!( + write_pin(dir.path(), "not-a-sha").is_err(), + "a malformed SHA must be rejected by validation, never written" + ); + Ok(()) + } + + /// [STUBRES-TYPESHED-DOWNLOAD]: pinning retires a custom folder in the + /// same transaction, exactly like the LSP's Download latest action. The + /// two step-3 sources are mutually exclusive, so a write that kept both + /// would be rejected outright and the download would end up unpinned. + #[test] + fn write_pin_retires_a_custom_typeshed_path() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + std::fs::write( + dir.path().join("pyproject.toml"), + "[tool.basilisk]\ntypeshed-path = \"vendor/typeshed\"\ntypeshed-store-path = \"store\"\n", + )?; + write_pin(dir.path(), "83c2518a9e6abbda0c44592c3483de459198f887")?; + let written = std::fs::read_to_string(dir.path().join("pyproject.toml"))?; + assert!( + written.contains("typeshed-commit = \"83c2518a9e6abbda0c44592c3483de459198f887\""), + "the resolved pin must be written: {written}" + ); + assert!( + !written.contains("typeshed-path"), + "the custom folder must be retired by the same write: {written}" + ); + assert!( + written.contains("typeshed-store-path = \"store\""), + "unrelated typeshed settings must survive untouched: {written}" + ); + Ok(()) + } + + #[test] + fn a_malformed_commit_argument_is_a_configuration_error() { + let api = FakeApi::new(fake_repo()); + // No transport is touched: the SHA fails validation before any request. + assert_eq!(download_exact("short", None, &api, &|_phase| {}), 2); + } + + /// The `run` dispatch reaches the same validation: a malformed pin exits + /// `2` before any transport work. + #[test] + fn run_rejects_a_malformed_sha_through_the_dispatch() -> Result<(), Box> + { + let dir = tempfile::tempdir()?; + let action = TypeshedAction::Download { + commit: Some("short".to_owned()), + workspace: dir.path().to_path_buf(), + }; + assert_eq!(run(action), 2); + Ok(()) + } + + /// `download --commit ` materialises the exact pin into the store and + /// writes no configuration ([STUBRES-TYPESHED-DOWNLOAD]). + #[test] + fn download_exact_materialises_the_pin_into_the_store() -> Result<(), Box> + { + let store = tempfile::tempdir()?; + let api = FakeApi::new(fake_repo()); + let sha = api.repo.commit.to_hex(); + assert_eq!( + download_exact(&sha, Some(store.path().to_path_buf()), &api, &|_phase| {}), + 0 + ); + assert_eq!( + std::fs::read_dir(store.path())?.count(), + 1, + "exactly one verified store entry must exist" + ); + Ok(()) + } + + /// A transport failure is exit `3` and writes nothing. + #[test] + fn a_transport_failure_downloading_an_exact_pin_is_exit_3( + ) -> Result<(), Box> { + let store = tempfile::tempdir()?; + let mut api = FakeApi::new(fake_repo()); + api.faults = Faults { + resolve_fails: true, + ..Faults::default() + }; + let sha = api.repo.commit.to_hex(); + assert_eq!( + download_exact(&sha, Some(store.path().to_path_buf()), &api, &|_phase| {}), + 3 + ); + assert_eq!(std::fs::read_dir(store.path())?.count(), 0); + Ok(()) + } + + /// `download` with no `--commit` resolves latest, stores it, and pegs the + /// resolved SHA as the workspace's `typeshed-commit` pin. + #[test] + fn download_latest_pins_the_resolved_sha() -> Result<(), Box> { + let workspace = tempfile::tempdir()?; + let store = tempfile::tempdir()?; + std::fs::write(workspace.path().join("pyproject.toml"), "[tool.basilisk]\n")?; + let api = FakeApi::new(fake_repo()); + assert_eq!( + download_latest_and_pin( + workspace.path(), + Some(store.path().to_path_buf()), + &api, + &|_phase| {} + ), + 0 + ); + let written = std::fs::read_to_string(workspace.path().join("pyproject.toml"))?; + assert!( + written.contains(&format!("typeshed-commit = \"{}\"", api.repo.commit)), + "the resolved SHA must be pegged as the pin: {written}" + ); + Ok(()) + } + + /// A failed latest download is exit `3` and leaves the configuration + /// untouched — no pin without verified bytes. + #[test] + fn a_failed_latest_download_writes_no_pin() -> Result<(), Box> { + let workspace = tempfile::tempdir()?; + let store = tempfile::tempdir()?; + std::fs::write(workspace.path().join("pyproject.toml"), "[tool.basilisk]\n")?; + let mut api = FakeApi::new(fake_repo()); + api.faults = Faults { + archive_fails: true, + ..Faults::default() + }; + assert_eq!( + download_latest_and_pin( + workspace.path(), + Some(store.path().to_path_buf()), + &api, + &|_phase| {} + ), + 3 + ); + let written = std::fs::read_to_string(workspace.path().join("pyproject.toml"))?; + assert!( + !written.contains("typeshed-commit"), + "no pin may be written for a failed download: {written}" + ); + Ok(()) + } + + /// A pin-write failure after a successful download is exit `2`; the store + /// entry is kept so re-running needs no re-download. + #[cfg(unix)] + #[test] + fn a_pin_write_failure_after_download_is_a_configuration_error( + ) -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt as _; + let workspace = tempfile::tempdir()?; + let store = tempfile::tempdir()?; + std::fs::write(workspace.path().join("pyproject.toml"), "[tool.basilisk]\n")?; + let api = FakeApi::new(fake_repo()); + std::fs::set_permissions(workspace.path(), std::fs::Permissions::from_mode(0o555))?; + let exit = download_latest_and_pin( + workspace.path(), + Some(store.path().to_path_buf()), + &api, + &|_phase| {}, + ); + std::fs::set_permissions(workspace.path(), std::fs::Permissions::from_mode(0o755))?; + assert_eq!(exit, 2); + assert_eq!( + std::fs::read_dir(store.path())?.count(), + 1, + "the verified store entry must survive the failed pin write" + ); + Ok(()) + } + + /// The full action surface offline: config discovery resolves the + /// workspace-relative store, the latest commit lands there, and the pin is + /// pegged — the exact flow `basilisk typeshed download` runs. + #[test] + fn download_action_resolves_the_store_from_workspace_config( + ) -> Result<(), Box> { + let workspace = tempfile::tempdir()?; + std::fs::write( + workspace.path().join("pyproject.toml"), + "[tool.basilisk]\ntypeshed-store-path = \"store\"\n", + )?; + let api = FakeApi::new(fake_repo()); + assert_eq!(download_action(None, workspace.path(), &api), 0); + assert_eq!( + std::fs::read_dir(workspace.path().join("store"))?.count(), + 1, + "the store entry must land in the config-resolved location" + ); + Ok(()) + } + + /// Every phase renders a distinct, human-readable progress label. + #[test] + fn every_download_phase_has_a_distinct_label() { + let labels = [ + phase_label(DownloadPhase::Resolving), + phase_label(DownloadPhase::FetchingTree), + phase_label(DownloadPhase::FetchingArchive), + phase_label(DownloadPhase::Verifying), + phase_label(DownloadPhase::Writing), + ]; + let unique: std::collections::BTreeSet<&str> = labels.iter().copied().collect(); + assert_eq!(unique.len(), labels.len()); + assert!(labels.iter().all(|label| !label.is_empty())); + } +} diff --git a/crates/basilisk-cli/tests/e2e_cross_module_final.rs b/crates/basilisk-cli/tests/e2e_cross_module_final.rs index 6cea1dd0..bddea9a6 100644 --- a/crates/basilisk-cli/tests/e2e_cross_module_final.rs +++ b/crates/basilisk-cli/tests/e2e_cross_module_final.rs @@ -1,4 +1,4 @@ -//! Tests for [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#chkarch-diag-ownership +//! Tests for [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP #![allow( clippy::allow_attributes, clippy::indexing_slicing, diff --git a/crates/basilisk-cli/tests/e2e_stub_resolution.rs b/crates/basilisk-cli/tests/e2e_stub_resolution.rs index d2445362..ff1e2465 100644 --- a/crates/basilisk-cli/tests/e2e_stub_resolution.rs +++ b/crates/basilisk-cli/tests/e2e_stub_resolution.rs @@ -104,12 +104,10 @@ fn custom_typeshed_overrides_stdlib_and_parses() { let config = basilisk_lsp::config::WorkspaceConfig { typeshed_path: Some(ts.clone()), - typeshed_cache: false, ..Default::default() }; let request = basilisk_lsp::config::typeshed_request(&config).unwrap(); - let snapshot = basilisk_stubs::typeshed::runtime::production_manager(request, None) - .unwrap() + let snapshot = basilisk_stubs::typeshed::runtime::production_manager(request) .snapshot() .unwrap(); assert_eq!( diff --git a/crates/basilisk-cli/tests/e2e_typeshed_config_validation.rs b/crates/basilisk-cli/tests/e2e_typeshed_config_validation.rs index 553b5440..799c0706 100644 --- a/crates/basilisk-cli/tests/e2e_typeshed_config_validation.rs +++ b/crates/basilisk-cli/tests/e2e_typeshed_config_validation.rs @@ -7,7 +7,7 @@ //! with the distinct configuration-error code 2 (never the diagnostics //! code 1, never a silent 0), prints a redacted, user-facing reason on //! stderr, and emits no diagnostics at all — a broken pin must never -//! silently substitute another source ([STUBRES-TYPESHED-ACQUIRE]). +//! silently substitute another source ([STUBRES-TYPESHED-OFFLINE]). #![allow( clippy::allow_attributes, clippy::expect_used, @@ -15,10 +15,15 @@ clippy::panic )] +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::sync::atomic::{AtomicU64, Ordering}; +use basilisk_config::{ + ConfigurationUpdate, RuleConfigUpdate, TypeshedConfigKey, TypeshedConfigUpdate, +}; + fn unique_dir(prefix: &str) -> PathBuf { static CTR: AtomicU64 = AtomicU64::new(0); let n = CTR.fetch_add(1, Ordering::Relaxed); @@ -41,6 +46,12 @@ fn check_with_config(dir: &Path, basilisk_table: &str) -> Output { ) .expect("write pyproject"); std::fs::write(dir.join("app.py"), "value: int = 1\n").expect("write app"); + run_check(dir) +} + +/// Run `basilisk check app.py` in `dir` against whatever configuration is on +/// disk, with the ambient venv scrubbed. +fn run_check(dir: &Path) -> Output { Command::new(env!("CARGO_BIN_EXE_basilisk")) .arg("check") .arg("app.py") @@ -50,6 +61,20 @@ fn check_with_config(dir: &Path, basilisk_table: &str) -> Output { .expect("spawn basilisk") } +/// The typeshed half of a configuration update, as both pin-writing surfaces +/// build it. +fn typeshed_update(entries: &[(TypeshedConfigKey, Option<&str>)]) -> ConfigurationUpdate { + ConfigurationUpdate { + rules: RuleConfigUpdate::default(), + typeshed: TypeshedConfigUpdate { + entries: entries + .iter() + .map(|(key, setting)| (*key, setting.map(str::to_owned))) + .collect::>(), + }, + } +} + /// Assert the fail-closed contract shared by every invalid typeshed setting: /// exit code 2, the exact reason on stderr, and zero diagnostics on stdout. fn assert_fails_closed(output: &Output, expected_reason: &str) { @@ -96,45 +121,151 @@ fn full_length_non_hex_typeshed_commit_fails_closed() { let _ = std::fs::remove_dir_all(&dir); } +/// [STUBRES-TYPESHED-PIN] / [TYPESHEDRT-SEGREGATION], through the real +/// binary: a well-formed pin that is not on this machine is VALID +/// configuration — the check TANKS HARD (exit 3, the spec's `NO SOURCE` +/// status line naming the recovery command, zero diagnostics) and the +/// checker NEVER attempts to download anything: the isolated store is left +/// byte-empty. #[test] -fn typeshed_url_without_sha_placeholder_fails_closed() { - let dir = unique_dir("url_no_sha"); +fn a_valid_missing_pin_tanks_hard_and_never_downloads() { + let dir = unique_dir("missing_pin"); + let pin = "0123456789012345678901234567890123456789"; let output = check_with_config( &dir, - "typeshed-url = \"https://mirror.example.com/typeshed.zip\"\n", + &format!("typeshed-commit = \"{pin}\"\ntypeshed-store-path = \"store\"\n"), ); - assert_fails_closed( - &output, - "typeshed-url must be HTTPS with exactly one {sha} placeholder", + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!( + output.status.code(), + Some(3), + "a missing pin is a hard failure, not a config error and never a pass, stdout: {stdout}, stderr: {stderr}" + ); + assert!( + stderr.contains("NO SOURCE") && stderr.contains(pin), + "stderr must carry the loud NO SOURCE line naming the pin, stderr: {stderr}" + ); + assert!( + stderr.contains("basilisk typeshed download"), + "stderr must name the explicit recovery command, stderr: {stderr}" + ); + assert!( + !stdout.contains("error["), + "no diagnostics may be emitted when the source is missing, stdout: {stdout}" + ); + let store = dir.join("store"); + let store_is_untouched = !store.exists() + || std::fs::read_dir(&store).is_ok_and(|mut entries| entries.next().is_none()); + assert!( + store_is_untouched, + "the checker must never write to or fetch into the store" ); let _ = std::fs::remove_dir_all(&dir); } +/// The retired download-policy keys (`typeshed-url`, `typeshed-cache`, +/// `typeshed-verify`, `typeshed-cache-path`) are no longer configuration: +/// they change nothing, trigger no download machinery, and their values are +/// never echoed back ([STUBRES-TYPESHED-CONFIG] redaction). #[test] -fn plain_http_typeshed_url_fails_closed() { - let dir = unique_dir("url_http"); +fn retired_download_policy_keys_are_inert_and_never_echoed() { + let dir = unique_dir("retired_keys"); let output = check_with_config( &dir, - "typeshed-url = \"http://mirror.example.com/{sha}.zip\"\n", + "typeshed-url = \"http://internal-host.corp.example/secret-{sha}.zip\"\ntypeshed-cache = false\ntypeshed-verify = false\ntypeshed-cache-path = \"secret-cache\"\n", ); - assert_fails_closed( - &output, - "typeshed-url must be HTTPS with exactly one {sha} placeholder", + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!( + output.status.code(), + Some(0), + "retired keys must not alter the bundled-default check, stdout: {stdout}, stderr: {stderr}" ); + for retired_value in ["internal-host.corp.example", "secret-cache"] { + assert!( + !stderr.contains(retired_value) && !stdout.contains(retired_value), + "a retired key's value must never be echoed back: `{retired_value}`, stdout: {stdout}, stderr: {stderr}" + ); + } let _ = std::fs::remove_dir_all(&dir); } +/// [STUBRES-TYPESHED-DOWNLOAD] agreement between the two pin-writing +/// surfaces: `basilisk typeshed download` (`write_pin` in +/// `crates/basilisk-cli/src/typeshed_cli.rs`) and the LSP's Download latest +/// button (`pin_update` in `crates/basilisk-lsp/src/typeshed_download.rs`) +/// build the SAME two-entry update — set `typeshed-commit`, retire +/// `typeshed-path` — because the two step-3 sources are mutually exclusive +/// ([STUBRES-TYPESHED]). This drives that shared `basilisk-config` +/// transaction over a workspace that names a custom folder and hands the +/// result to the real binary. The commit-only half-update, which would leave +/// both sources named, is refused wholesale — so a surface that skipped the +/// retirement would download bytes and then fail to pin them. #[test] -fn typeshed_url_with_two_sha_placeholders_fails_closed() { - let dir = unique_dir("url_two_sha"); - let output = check_with_config( - &dir, - "typeshed-url = \"https://mirror.example.com/{sha}/{sha}.zip\"\n", +fn pinning_a_downloaded_commit_retires_the_custom_folder() { + let pin = "0123456789012345678901234567890123456789"; + let dir = unique_dir("pin_retires_path"); + std::fs::create_dir_all(dir.join("ts").join("stdlib")).expect("create custom tree"); + std::fs::write(dir.join("app.py"), "value: int = 1\n").expect("write app"); + std::fs::write( + dir.join("pyproject.toml"), + "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ntypeshed-path = \"ts\"\ntypeshed-store-path = \"store\"\n", + ) + .expect("write pyproject"); + + let document = basilisk_config::discover_config_document(&dir).expect("discover config"); + let commit_only = typeshed_update(&[(TypeshedConfigKey::TypeshedCommit, Some(pin))]); + assert!( + basilisk_config::build_configuration_patch(&document, &commit_only).is_err(), + "a pin write that keeps the custom folder names two sources at once and must be refused" ); - assert_fails_closed( - &output, - "typeshed-url must be HTTPS with exactly one {sha} placeholder", + + let patch = basilisk_config::build_configuration_patch( + &document, + &typeshed_update(&[ + (TypeshedConfigKey::TypeshedCommit, Some(pin)), + (TypeshedConfigKey::TypeshedPath, None), + ]), + ) + .expect("the retiring pin write is a valid transaction"); + basilisk_config::apply_config_patch(&patch).expect("apply the pin write"); + + let written = std::fs::read_to_string(dir.join("pyproject.toml")).expect("read pyproject"); + assert!( + written.contains(&format!("typeshed-commit = \"{pin}\"")), + "the resolved pin must be written: {written}" + ); + assert!( + !written.contains("typeshed-path"), + "the custom folder must be retired by the same write: {written}" + ); + assert!( + written.contains("typeshed-store-path = \"store\""), + "unrelated typeshed settings must survive untouched: {written}" + ); + + // The real binary reads exactly one source out of the written file: the + // pin. It fails on resolution (`NO SOURCE`), never on configuration. + let output = run_check(&dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("mutually exclusive"), + "the written configuration must name a single source, stderr: {stderr}" + ); + assert_eq!( + output.status.code(), + Some(3), + "the lone pin is valid configuration whose bytes are absent, stdout: {stdout}, stderr: {stderr}" ); + assert!( + stderr.contains("NO SOURCE") && stderr.contains(pin), + "stderr must carry the NO SOURCE line naming the freshly written pin, stderr: {stderr}" + ); + let _ = std::fs::remove_dir_all(&dir); } @@ -153,45 +284,25 @@ fn typeshed_path_with_commit_pin_fails_closed() { let _ = std::fs::remove_dir_all(&dir); } +/// A well-formed pin clears configuration validation: whatever happens next +/// is source resolution (`NO SOURCE`), never a configuration error. #[test] -fn config_error_reason_never_echoes_the_configured_mirror_value() { - let dir = unique_dir("redacted_mirror"); - let secret = "http://internal-host.corp.example/secret-{sha}.zip"; - let output = check_with_config(&dir, &format!("typeshed-url = \"{secret}\"\n")); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert_eq!( - output.status.code(), - Some(2), - "the invalid mirror must fail closed, stderr: {stderr}" - ); - assert!( - !stderr.contains("internal-host.corp.example"), - "the configured mirror value must never be echoed back ([STUBRES-TYPESHED-CONFIG] redaction), stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn valid_full_sha_paired_with_valid_mirror_passes_validation() { +fn valid_full_sha_clears_validation_and_fails_only_on_resolution() { let dir = unique_dir("valid_pin_shape"); - // An unreachable-but-well-formed mirror: validation must ACCEPT the - // shape; the later transport failure is a different, non-config error. let output = check_with_config( &dir, - "typeshed-commit = \"0123456789012345678901234567890123456789\"\ntypeshed-url = \"https://127.0.0.1:1/{sha}.zip\"\ntypeshed-cache = false\n", + "typeshed-commit = \"0123456789012345678901234567890123456789\"\ntypeshed-store-path = \"store\"\n", ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( !stderr.contains("configuration error"), - "a well-formed pin + mirror must clear validation — any failure past this point is transport, not configuration, stderr: {stderr}" + "a well-formed pin must clear validation — any failure past this point is resolution, not configuration, stderr: {stderr}" ); assert_ne!( output.status.code(), Some(0), - "an unreachable mirror with an exact pin must not silently pass ([STUBRES-TYPESHED-ACQUIRE] fail-closed), stderr: {stderr}" + "a pin that is not on this machine must not silently pass ([STUBRES-TYPESHED-PIN] fail-closed), stderr: {stderr}" ); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/basilisk-cli/tests/e2e_typeshed_path_heavy.rs b/crates/basilisk-cli/tests/e2e_typeshed_path_heavy.rs index 8861039f..142e8745 100644 --- a/crates/basilisk-cli/tests/e2e_typeshed_path_heavy.rs +++ b/crates/basilisk-cli/tests/e2e_typeshed_path_heavy.rs @@ -132,8 +132,9 @@ fn typeshed_path_full_lifecycle_through_pyproject() { seed_typeshed(&dir, "ts", &[("os.pyi", "def uname() -> str: ...\n")]); write(&dir, "app.py", APP_OS_AND_FRACTIONS); - // ── Interaction 1: NO typeshed-path → the default Latest-first runtime - // source resolves both stdlib modules, so the project is clean. ── + // ── Interaction 1: NO typeshed-path → the default pinned source (an unset + // `typeshed-commit` selects the bundled commit, served from the embedded + // ZIP) resolves both stdlib modules, so the project is clean. ── write( &dir, "pyproject.toml", @@ -182,8 +183,9 @@ fn typeshed_path_full_lifecycle_through_pyproject() { .expect("remove fractions stub"); assert_flags_unresolved(&check(&dir), "fractions", &["os", "uname"]); - // ── Interaction 5: remove typeshed-path entirely → the default Latest-first - // runtime source becomes active again, so the project is clean once more. ── + // ── Interaction 5: remove typeshed-path entirely → the default pinned + // source (the bundled commit) becomes active again, so the project is clean + // once more. ── write( &dir, "pyproject.toml", diff --git a/crates/basilisk-cli/tests/mcp_stdio_tests.rs b/crates/basilisk-cli/tests/mcp_stdio_tests.rs index 8c8c6d12..042aca52 100644 --- a/crates/basilisk-cli/tests/mcp_stdio_tests.rs +++ b/crates/basilisk-cli/tests/mcp_stdio_tests.rs @@ -22,6 +22,26 @@ fn custom_typeshed_workspace() -> Result Result, Box> { + let requests = [ + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"e2e","version":"1"}}}), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}), + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"basilisk_typeshed_status","arguments":{}}}), + ]; + let mut payload = Vec::new(); + for request in requests { + payload.extend_from_slice(serde_json::to_string(&request)?.as_bytes()); + payload.push(b'\n'); + } + run_raw_session(workspace, &payload) +} + +/// Drive one MCP session over the spawned binary's stdio with raw bytes, so +/// tests can send lines no serializer would produce (invalid UTF-8, arrays). +fn run_raw_session( + workspace: &std::path::Path, + payload: &[u8], +) -> Result, Box> { let mut child = Command::new(env!("CARGO_BIN_EXE_basilisk")) .arg("mcp") .arg("--workspace") @@ -30,17 +50,8 @@ fn run_session(workspace: &std::path::Path) -> Result, Box Result<(), Box Result<(), Box> +{ + let workspace = custom_typeshed_workspace()?; + let mut payload: Vec = vec![0xFF, 0xFE, b'\n']; + for request in [ + json!([1]), + json!({"jsonrpc":"2.0","id":true,"method":"ping"}), + json!({"jsonrpc":"2.0","id":1}), + json!({"jsonrpc":"1.0","id":2,"method":"ping"}), + json!({"jsonrpc":"2.0","id":3,"method":"initialize","params":{}}), + json!({"jsonrpc":"2.0","id":4,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"e2e","version":"1"}}}), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + json!({"jsonrpc":"2.0","id":5,"method":"ping"}), + json!({"jsonrpc":"2.0","id":6,"method":"resources/list"}), + json!({"jsonrpc":"2.0","id":7,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION}}), + json!({"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"nope","arguments":{}}}), + ] { + payload.extend_from_slice(serde_json::to_string(&request)?.as_bytes()); + payload.push(b'\n'); + } + let responses = run_raw_session(workspace.path(), &payload)?; + let codes: Vec> = responses + .iter() + .map(|response| response.pointer("/error/code").and_then(Value::as_i64)) + .collect(); + assert_eq!( + codes, + vec![ + Some(-32700), + Some(-32600), + Some(-32600), + Some(-32600), + Some(-32600), + Some(-32602), + None, + None, + Some(-32601), + Some(-32600), + Some(-32602), + ], + "each guard must answer with its prescribed code: {responses:?}" + ); + Ok(()) +} diff --git a/crates/basilisk-cli/tests/stub_cli_tests.rs b/crates/basilisk-cli/tests/stub_cli_tests.rs index 2eb009c5..5453de52 100644 --- a/crates/basilisk-cli/tests/stub_cli_tests.rs +++ b/crates/basilisk-cli/tests/stub_cli_tests.rs @@ -179,3 +179,18 @@ fn generate_all_with_no_untyped_imports_succeeds() -> Result<(), Box Result<(), Box> { + let project = TestProject::new("status_empty")?; + let output = project.command().args(["stubs", "status"]).output()?; + assert!(output.status.success(), "{}", output_text(&output)); + assert!( + String::from_utf8_lossy(&output.stdout).contains("No generated stubs found"), + "{}", + output_text(&output) + ); + Ok(()) +} diff --git a/crates/basilisk-cli/tests/typeshed_cli_status_tests.rs b/crates/basilisk-cli/tests/typeshed_cli_status_tests.rs index 779490f9..a1f720ba 100644 --- a/crates/basilisk-cli/tests/typeshed_cli_status_tests.rs +++ b/crates/basilisk-cli/tests/typeshed_cli_status_tests.rs @@ -14,7 +14,7 @@ fn check_uses_custom_typeshed_and_routes_status_only_to_stderr( std::fs::write(workspace.path().join("app.py"), "from os import getcwd\n")?; std::fs::write( workspace.path().join("pyproject.toml"), - "[tool.basilisk]\ntypeshed-path = \"typeshed\"\ntypeshed-cache = false\n", + "[tool.basilisk]\ntypeshed-path = \"typeshed\"\n", )?; let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) @@ -33,8 +33,15 @@ fn check_uses_custom_typeshed_and_routes_status_only_to_stderr( assert!(!stdout.contains("UNPINNED")); assert!(stderr.contains("typeshed source status"), "{stderr}"); assert!(stderr.contains("active_source=\"custom\""), "{stderr}"); - assert!(stderr.contains("provenance=UserManaged"), "{stderr}"); - assert!(stderr.contains("signed_release=false"), "{stderr}"); + assert!(stderr.contains("license_status=NotSupplied"), "{stderr}"); + assert!( + !stderr.contains("provenance="), + "active_source IS the trust story — no provenance field may reappear: {stderr}" + ); + assert!( + !stderr.contains("signed_release="), + "active_source IS the trust story — no signed_release field may reappear: {stderr}" + ); let unpinned = stderr.find("warning_code=\"UNPINNED\""); let user_managed = stderr.find("warning_code=\"USER-MANAGED SOURCE\""); assert!( @@ -46,8 +53,33 @@ fn check_uses_custom_typeshed_and_routes_status_only_to_stderr( Ok(()) } +/// [STUBRES-TYPESHED-DOWNLOAD]: a malformed `--commit` is rejected by +/// validation (exit `2`) before any transport work — safe to assert offline. +#[test] +fn a_malformed_download_commit_is_rejected_offline() -> Result<(), Box> { + let workspace = tempfile::tempdir()?; + let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) + .args([ + "typeshed", + "download", + "--commit", + "not-a-sha", + "--workspace", + ]) + .arg(workspace.path()) + .output()?; + assert_eq!( + output.status.code(), + Some(2), + "stdout={}; stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + #[test] -fn exact_commit_transport_failure_does_not_fall_back() -> Result<(), Box> { +fn a_pin_missing_from_the_store_does_not_fall_back() -> Result<(), Box> { let workspace = tempfile::tempdir()?; std::fs::write(workspace.path().join("app.py"), "from os import getcwd\n")?; std::fs::write( @@ -55,8 +87,7 @@ fn exact_commit_transport_failure_does_not_fall_back() -> Result<(), Box Result<(), Box Result<(), ConfigDocumentError> { - for key in [ - "typeshed-path", - "typeshed-commit", - "typeshed-url", - "typeshed-cache-path", - ] { + for key in ["typeshed-path", "typeshed-commit", "typeshed-store-path"] { if basilisk.get(key).is_some_and(|value| !value.is_str()) { return invalid(path, &format!("`{key}` must be a string")); } } - for key in ["typeshed-cache", "typeshed-verify"] { - if basilisk.get(key).is_some_and(|value| !value.is_bool()) { - return invalid(path, &format!("`{key}` must be a boolean")); - } - } if basilisk.contains_key("typeshed-path") && basilisk.contains_key("typeshed-commit") { return invalid( path, @@ -262,14 +252,6 @@ fn validate_typeshed_settings( ); } } - if let Some(url) = basilisk.get("typeshed-url").and_then(toml::Value::as_str) { - if !crate::is_valid_typeshed_url_template(url) { - return invalid( - path, - "`typeshed-url` must be HTTPS with exactly one {sha} placeholder", - ); - } - } Ok(()) } diff --git a/crates/basilisk-config/src/editor/patch.rs b/crates/basilisk-config/src/editor/patch.rs index 2f1843ee..31dbb199 100644 --- a/crates/basilisk-config/src/editor/patch.rs +++ b/crates/basilisk-config/src/editor/patch.rs @@ -29,21 +29,16 @@ pub struct RuleConfigUpdate { pub rule_tags: BTreeMap>, } -/// Closed persistence allowlist for Typeshed acquisition settings. +/// Closed persistence allowlist for typeshed settings — the whole runtime +/// surface is these three keys ([STUBRES-TYPESHED-CONFIG]). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum TypeshedConfigKey { - /// Custom canonical step-3 folder. + /// Custom typeshed folder. TypeshedPath, - /// Exact full commit SHA. + /// Exact full commit SHA pin. TypeshedCommit, - /// Known-SHA archive mirror template. - TypeshedUrl, - /// Immutable ZIP cache directory. - TypeshedCachePath, - /// Whether accepted downloads are reused. - TypeshedCache, - /// Whether the content gate is enabled. - TypeshedVerify, + /// Verified content-addressed store directory. + TypeshedStorePath, } impl TypeshedConfigKey { @@ -51,28 +46,17 @@ impl TypeshedConfigKey { match self { Self::TypeshedPath => "typeshed-path", Self::TypeshedCommit => "typeshed-commit", - Self::TypeshedUrl => "typeshed-url", - Self::TypeshedCachePath => "typeshed-cache-path", - Self::TypeshedCache => "typeshed-cache", - Self::TypeshedVerify => "typeshed-verify", + Self::TypeshedStorePath => "typeshed-store-path", } } } -/// A TOML scalar accepted by a Typeshed setting update. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TypeshedConfigValue { - /// A path, URL template, or full SHA. - Text(String), - /// A cache or verification toggle. - Boolean(bool), -} - -/// Atomic Typeshed setting updates. `None` removes the explicit key. +/// Atomic typeshed setting updates. Every key holds a TOML string (a path or +/// a full SHA); `None` removes the explicit key. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct TypeshedConfigUpdate { - /// Key to replacement scalar, or `None` to remove the explicit key. - pub entries: BTreeMap>, + /// Key to replacement string, or `None` to remove the explicit key. + pub entries: BTreeMap>, } /// One atomic configuration-editor transaction. @@ -172,12 +156,11 @@ fn patch_toml( fn apply_typeshed_updates( table: &mut Table, - entries: &BTreeMap>, + entries: &BTreeMap>, ) { for (key, setting) in entries { match setting { - Some(TypeshedConfigValue::Text(text)) => table[key.as_str()] = value(text.as_str()), - Some(TypeshedConfigValue::Boolean(enabled)) => table[key.as_str()] = value(*enabled), + Some(text) => table[key.as_str()] = value(text.as_str()), None => { let _ = table.remove(key.as_str()); } diff --git a/crates/basilisk-config/src/editor/tests.rs b/crates/basilisk-config/src/editor/tests.rs index 13033cc2..f7f37858 100644 --- a/crates/basilisk-config/src/editor/tests.rs +++ b/crates/basilisk-config/src/editor/tests.rs @@ -11,7 +11,7 @@ use super::{ active_config_path, apply_config_patch, build_configuration_patch, build_rule_patch, content_revision, discover_config_document, discover_config_document_with_content, ConfigDocument, ConfigDocumentError, ConfigPatch, ConfigurationUpdate, RuleConfigUpdate, - TypeshedConfigKey, TypeshedConfigUpdate, TypeshedConfigValue, + TypeshedConfigKey, TypeshedConfigUpdate, }; use crate::{BasiliskConfig, RuleSeverity}; @@ -97,9 +97,8 @@ fn malformed_typeshed_settings_are_invalid() { let root = temp_root("bad_typeshed_settings"); for content in [ "[tool.basilisk]\ntypeshed-commit = 42\n", - "[tool.basilisk]\ntypeshed-cache = \"false\"\n", "[tool.basilisk]\ntypeshed-commit = \"short\"\n", - "[tool.basilisk]\ntypeshed-url = \"http://mirror/{sha}.zip\"\n", + "[tool.basilisk]\ntypeshed-store-path = false\n", "[tool.basilisk]\ntypeshed-path = \"custom\"\ntypeshed-commit = \"83c2518a9e6abbda0c44592c3483de459198f887\"\n", ] { let result = discover_config_document_with_content(&root, content.to_owned()); @@ -380,7 +379,7 @@ fn typeshed_settings_patch_atomically_and_preserve_project_content() { let root = temp_root("typeshed_patch"); let document = document_for( &root, - "# keep\n[project]\nname = \"demo\"\n\n[tool.basilisk]\ntypeshed-cache = true\n", + "# keep\n[project]\nname = \"demo\"\n\n[tool.basilisk]\n", ); let update = ConfigurationUpdate { rules: set_rule("BSK-0001", RuleSeverity::Warning), @@ -388,13 +387,11 @@ fn typeshed_settings_patch_atomically_and_preserve_project_content() { entries: BTreeMap::from([ ( TypeshedConfigKey::TypeshedCommit, - Some(TypeshedConfigValue::Text( - "83c2518a9e6abbda0c44592c3483de459198f887".to_owned(), - )), + Some("83c2518a9e6abbda0c44592c3483de459198f887".to_owned()), ), ( - TypeshedConfigKey::TypeshedCache, - Some(TypeshedConfigValue::Boolean(false)), + TypeshedConfigKey::TypeshedStorePath, + Some(".cache/typeshed-store".to_owned()), ), ]), }, @@ -405,8 +402,13 @@ fn typeshed_settings_patch_atomically_and_preserve_project_content() { assert!(patch .content .contains("typeshed-commit = \"83c2518a9e6abbda0c44592c3483de459198f887\"")); - assert!(patch.content.contains("typeshed-cache = false")); - assert_eq!(patch.config.typeshed_cache, Some(false)); + assert!(patch + .content + .contains("typeshed-store-path = \".cache/typeshed-store\"")); + assert_eq!( + patch.config.typeshed_store_path, + Some(std::path::PathBuf::from(".cache/typeshed-store")) + ); assert_eq!( patch.config.typeshed_commit.as_deref(), Some("83c2518a9e6abbda0c44592c3483de459198f887") @@ -421,19 +423,19 @@ fn typeshed_setting_removal_is_allowlisted_and_narrow() { let root = temp_root("typeshed_remove"); let document = document_for( &root, - "[tool.basilisk]\n# keep mirror\ntypeshed-url = \"https://mirror.invalid/{sha}.zip\"\ntypeshed-verify = false\n", + "[tool.basilisk]\n# keep store\ntypeshed-store-path = \".cache/store\"\ntypeshed-commit = \"83c2518a9e6abbda0c44592c3483de459198f887\"\n", ); let update = ConfigurationUpdate { rules: RuleConfigUpdate::default(), typeshed: TypeshedConfigUpdate { - entries: BTreeMap::from([(TypeshedConfigKey::TypeshedVerify, None)]), + entries: BTreeMap::from([(TypeshedConfigKey::TypeshedCommit, None)]), }, }; let patch = build_configuration_patch(&document, &update).unwrap(); - assert!(!patch.content.contains("typeshed-verify")); - assert!(patch.content.contains("# keep mirror")); - assert!(patch.content.contains("typeshed-url")); - assert!(patch.config.typeshed_verify.is_none()); + assert!(!patch.content.contains("typeshed-commit")); + assert!(patch.content.contains("# keep store")); + assert!(patch.content.contains("typeshed-store-path")); + assert!(patch.config.typeshed_commit.is_none()); let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/basilisk-config/src/lib.rs b/crates/basilisk-config/src/lib.rs index 20e25c0d..a04b8be9 100644 --- a/crates/basilisk-config/src/lib.rs +++ b/crates/basilisk-config/src/lib.rs @@ -21,9 +21,9 @@ pub use editor::{ active_config_path, apply_config_patch, build_configuration_patch, build_rule_patch, discover_config_document, discover_config_document_with_content, ConfigDocument, ConfigDocumentError, ConfigPatch, ConfigurationUpdate, RuleConfigUpdate, TypeshedConfigKey, - TypeshedConfigUpdate, TypeshedConfigValue, + TypeshedConfigUpdate, }; -pub use parse::{is_full_commit_sha, is_valid_typeshed_url_template, BasiliskConfig, RuleTables}; +pub use parse::{is_full_commit_sha, BasiliskConfig, RuleTables}; pub use paths::path_matches_pattern; pub use severity::RuleSeverity; @@ -248,8 +248,8 @@ typeshed-path = "typeshed-mp" ); } - /// [STUBRES-TYPESHED-CONFIG]: every runtime Typeshed setting is parsed - /// without manufacturing a Python target or mapping it to a commit. + /// [STUBRES-TYPESHED-CONFIG]: the whole runtime typeshed surface is three + /// string keys, parsed verbatim. #[test] fn runtime_typeshed_settings_parse_as_one_source_policy() { with_temp_cfg_dir( @@ -260,10 +260,7 @@ typeshed-path = "typeshed-mp" [tool.basilisk] typeshed-path = "custom-typeshed" typeshed-commit = "83c2518a9e6abbda0c44592c3483de459198f887" -typeshed-url = "https://mirror.invalid/typeshed-{sha}.zip" -typeshed-cache-path = ".cache/typeshed" -typeshed-cache = false -typeshed-verify = false +typeshed-store-path = ".cache/typeshed-store" "#, )], |cfg| { @@ -276,23 +273,17 @@ typeshed-verify = false Some("83c2518a9e6abbda0c44592c3483de459198f887") ); assert_eq!( - cfg.typeshed_url.as_deref(), - Some("https://mirror.invalid/typeshed-{sha}.zip") + cfg.typeshed_store_path, + Some(std::path::PathBuf::from(".cache/typeshed-store")) ); - assert_eq!( - cfg.typeshed_cache_path, - Some(std::path::PathBuf::from(".cache/typeshed")) - ); - assert_eq!(cfg.typeshed_cache, Some(false)); - assert_eq!(cfg.typeshed_verify, Some(false)); assert_eq!(cfg.python_version, None); }, ); } - /// [STUBRES-TYPESHED-CONFIG]: unset acquisition keys stay `None`, so the - /// selector applies Latest (no pin), default codeload URL, OS cache, and - /// cache/verify on. + /// [STUBRES-TYPESHED-CONFIG]: unset keys stay `None`, so the runtime uses + /// the bundled commit (with `UNPINNED`) and the OS store directory — + /// never a download. #[test] fn typeshed_acquisition_keys_default_to_none() { with_temp_cfg_dir( @@ -300,10 +291,8 @@ typeshed-verify = false &[("pyproject.toml", "[tool.basilisk]\n")], |cfg| { assert!(cfg.typeshed_commit.is_none()); - assert!(cfg.typeshed_url.is_none()); - assert!(cfg.typeshed_cache_path.is_none()); - assert!(cfg.typeshed_cache.is_none()); - assert!(cfg.typeshed_verify.is_none()); + assert!(cfg.typeshed_path.is_none()); + assert!(cfg.typeshed_store_path.is_none()); }, ); } diff --git a/crates/basilisk-config/src/parse.rs b/crates/basilisk-config/src/parse.rs index 57d721d1..8c4d3a15 100644 --- a/crates/basilisk-config/src/parse.rs +++ b/crates/basilisk-config/src/parse.rs @@ -71,30 +71,15 @@ pub struct BasiliskConfig { /// standard-library stubs ([STUBRES-CUSTOM-TYPESHED]). pub typeshed_path: Option, - /// Exact `python/typeshed` commit to acquire at step 3 - /// ([STUBRES-TYPESHED-CONFIG]). A full SHA. Unset selects Latest `main`. - /// A set pin fails closed — it never silently substitutes another SHA. + /// Exact `python/typeshed` commit pin ([STUBRES-TYPESHED-CONFIG]). A full + /// SHA. Unset means the bundled commit with an `UNPINNED` warning. A set + /// pin fails closed — the checker never downloads and never substitutes + /// another SHA; a pin not on this machine is `NO SOURCE`. pub typeshed_commit: Option, - /// Archive-mirror URL template containing `{sha}` - /// ([STUBRES-TYPESHED-CONFIG]). Unset uses GitHub codeload. A mirror cannot - /// resolve Latest — it only serves an already-known SHA. - pub typeshed_url: Option, - - /// Directory holding gate-accepted, content-addressed typeshed ZIPs - /// ([STUBRES-TYPESHED-CONFIG]). Unset uses the OS cache directory. - pub typeshed_cache_path: Option, - - /// Whether to reuse gate-accepted cached ZIPs ([STUBRES-TYPESHED-CONFIG]). - /// `None` means the spec default `true`; `false` downloads, validates, and - /// discards. This forces a fresh acquisition but is not hermetic. - pub typeshed_cache: Option, - - /// Whether to content-attest the archive against the trusted git tree - /// ([STUBRES-TYPESHED-CONFIG]). `None` means the spec default `true`; - /// `false` reports `UNVERIFIED` and never bypasses the safety, shape, or - /// license gates. - pub typeshed_verify: Option, + /// The verified content-addressed typeshed store directory + /// ([STUBRES-TYPESHED-STORE]). Unset uses the OS cache directory. + pub typeshed_store_path: Option, /// Nearest-first chain of `[tool.basilisk]` rule tables on the ancestor /// walk ([CHKARCH-CONFIG-DISCOVERY]). Rules are never merged: resolution @@ -131,10 +116,7 @@ impl Default for BasiliskConfig { stub_paths: Vec::new(), typeshed_path: None, typeshed_commit: None, - typeshed_url: None, - typeshed_cache_path: None, - typeshed_cache: None, - typeshed_verify: None, + typeshed_store_path: None, rule_chain: Vec::new(), python_version: None, python_platform: None, @@ -241,10 +223,7 @@ impl BasiliskConfig { self.typeshed_commit = child.typeshed_commit; } } - self.typeshed_url = child.typeshed_url.or(self.typeshed_url); - self.typeshed_cache_path = child.typeshed_cache_path.or(self.typeshed_cache_path); - self.typeshed_cache = child.typeshed_cache.or(self.typeshed_cache); - self.typeshed_verify = child.typeshed_verify.or(self.typeshed_verify); + self.typeshed_store_path = child.typeshed_store_path.or(self.typeshed_store_path); self.python_version = child.python_version.or(self.python_version); self.python_platform = child.python_platform.or(self.python_platform); self.narrow_attributes_across_calls = child @@ -303,30 +282,14 @@ pub(crate) fn parse_pyproject_content(content: &str) -> Option { cfg.typeshed_path = Some(PathBuf::from(val)); } - // [STUBRES-TYPESHED-CONFIG]: runtime-acquisition keys. `typeshed-commit` - // pins an exact SHA (unset => Latest); `typeshed-url` is a `{sha}` mirror - // template; `typeshed-cache-path` relocates cached ZIPs; `typeshed-cache` - // and `typeshed-verify` are tri-state (`None` => spec default `true`). + // [STUBRES-TYPESHED-CONFIG]: `typeshed-commit` pins an exact SHA (unset + // => bundled commit + UNPINNED); `typeshed-store-path` relocates the + // verified store. That is the whole runtime surface. if let Some(val) = basilisk.get("typeshed-commit").and_then(|v| v.as_str()) { cfg.typeshed_commit = Some(val.to_owned()); } - if let Some(val) = basilisk.get("typeshed-url").and_then(|v| v.as_str()) { - cfg.typeshed_url = Some(val.to_owned()); - } - if let Some(val) = basilisk.get("typeshed-cache-path").and_then(|v| v.as_str()) { - cfg.typeshed_cache_path = Some(PathBuf::from(val)); - } - if let Some(val) = basilisk - .get("typeshed-cache") - .and_then(toml::Value::as_bool) - { - cfg.typeshed_cache = Some(val); - } - if let Some(val) = basilisk - .get("typeshed-verify") - .and_then(toml::Value::as_bool) - { - cfg.typeshed_verify = Some(val); + if let Some(val) = basilisk.get("typeshed-store-path").and_then(|v| v.as_str()) { + cfg.typeshed_store_path = Some(PathBuf::from(val)); } // [CHKARCH-CONFIG-MODEL]: this file's one rule table. An empty or absent @@ -402,27 +365,10 @@ pub fn is_full_commit_sha(sha: &str) -> bool { sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) } -/// Whether `template` is a usable `typeshed-url` archive-mirror template -/// ([STUBRES-TYPESHED-CONFIG], [STUBRES-TYPESHED-ACQUIRE]). -/// -/// Two requirements, both load-bearing for a secure, deterministic mirror: -/// - **HTTPS scheme** — acquisition is over authenticated HTTPS only; a plain -/// `http`/`file` mirror is rejected so bytes can never arrive over an -/// unauthenticated transport. -/// - **Exactly one `{sha}` placeholder** — a mirror cannot resolve Latest, so -/// without a `{sha}` slot it could only serve one hard-coded commit; and more -/// than one slot makes the substituted URL ambiguous. -#[must_use] -pub fn is_valid_typeshed_url_template(template: &str) -> bool { - template.starts_with("https://") && template.matches("{sha}").count() == 1 -} - -/// Emit structured warnings for malformed typeshed-acquisition values -/// ([STUBRES-TYPESHED-CONFIG]). Values are kept verbatim on the config so the -/// acquisition core can fail closed on a bad pin rather than silently drop it; -/// this only surfaces the problem. Raw values are never logged — a mirror URL -/// may embed a token, so only the reason is recorded (URLs are redacted in -/// logs, [STUBRES-TYPESHED-ACQUIRE]). +/// Emit a structured warning for a malformed `typeshed-commit` pin +/// ([STUBRES-TYPESHED-CONFIG]). The value is kept verbatim on the config so +/// the runtime fails closed on a bad pin rather than silently dropping it; +/// this only surfaces the problem. The raw value is never logged. fn warn_on_malformed_typeshed_values(cfg: &BasiliskConfig) { if let Some(sha) = cfg.typeshed_commit.as_deref() { if !is_full_commit_sha(sha) { @@ -432,13 +378,6 @@ fn warn_on_malformed_typeshed_values(cfg: &BasiliskConfig) { ); } } - if let Some(url) = cfg.typeshed_url.as_deref() { - if !is_valid_typeshed_url_template(url) { - tracing::warn!( - "typeshed-url is not a valid HTTPS template with exactly one {{sha}} placeholder" - ); - } - } } #[cfg(test)] @@ -446,7 +385,7 @@ mod validation_tests { use std::collections::HashMap; use std::path::PathBuf; - use super::{is_full_commit_sha, is_valid_typeshed_url_template, BasiliskConfig, RuleSeverity}; + use super::{is_full_commit_sha, BasiliskConfig, RuleSeverity}; /// [STUBRES-TYPESHED-CONFIG]: only a full 40-char hex SHA is a valid pin. #[test] @@ -476,34 +415,6 @@ mod validation_tests { assert!(!is_full_commit_sha("")); } - /// [STUBRES-TYPESHED-CONFIG]/[STUBRES-TYPESHED-ACQUIRE]: a mirror template - /// MUST be HTTPS and carry exactly one `{sha}`. - #[test] - fn url_template_requires_https_and_exactly_one_sha() { - assert!(is_valid_typeshed_url_template( - "https://mirror.example/typeshed/{sha}.zip" - )); - assert!(is_valid_typeshed_url_template( - "https://host/{sha}/archive.zip" - )); - // No placeholder — a mirror that can only serve one commit is invalid. - assert!(!is_valid_typeshed_url_template( - "https://mirror.example/typeshed/latest.zip" - )); - // A near-miss placeholder name does not count. - assert!(!is_valid_typeshed_url_template("https://host/{commit}.zip")); - // More than one `{sha}` is ambiguous — rejected. - assert!(!is_valid_typeshed_url_template( - "https://host/{sha}/{sha}.zip" - )); - // Non-HTTPS transports are rejected even with a valid placeholder. - assert!(!is_valid_typeshed_url_template( - "http://mirror.example/typeshed/{sha}.zip" - )); - assert!(!is_valid_typeshed_url_template("file:///local/{sha}.zip")); - assert!(!is_valid_typeshed_url_template("")); - } - #[test] fn nearer_typeshed_selection_replaces_inherited_selection_atomically() { let ancestor_pin = BasiliskConfig { diff --git a/crates/basilisk-db/src/cache.rs b/crates/basilisk-db/src/cache.rs index a0aad4d1..a1e712bd 100644 --- a/crates/basilisk-db/src/cache.rs +++ b/crates/basilisk-db/src/cache.rs @@ -1,5 +1,5 @@ //! Implements [CHKCACHE-ENTRY] / [CHKCACHE-FINGERPRINT]. -//! See docs/specs/CHECKER-CACHE-SPEC.md#chkcache-entry +//! See docs/specs/CHECKER-CACHE-SPEC.md#CHKCACHE-ENTRY //! //! A generic, content-addressed, on-disk result cache. //! diff --git a/crates/basilisk-db/tests/cache_tests.rs b/crates/basilisk-db/tests/cache_tests.rs index 918bf7c5..311bc8ca 100644 --- a/crates/basilisk-db/tests/cache_tests.rs +++ b/crates/basilisk-db/tests/cache_tests.rs @@ -1,5 +1,5 @@ //! Tests for [CHKCACHE-ENTRY] / [CHKCACHE-FINGERPRINT] / [CHKCACHE-CONTRACT]. -//! See docs/specs/CHECKER-CACHE-SPEC.md#chkcache-entry +//! See docs/specs/CHECKER-CACHE-SPEC.md#CHKCACHE-ENTRY #![allow(clippy::allow_attributes, clippy::unwrap_used, clippy::expect_used)] //! Crate-boundary tests for the result cache, covering every soundness branch: //! a hit is returned only when the fingerprint and every recorded file match. diff --git a/crates/basilisk-lsp/Cargo.toml b/crates/basilisk-lsp/Cargo.toml index eca84ce1..56ebabb0 100644 --- a/crates/basilisk-lsp/Cargo.toml +++ b/crates/basilisk-lsp/Cargo.toml @@ -13,6 +13,7 @@ basilisk-checker.workspace = true basilisk-common.workspace = true basilisk-uv.workspace = true basilisk-stubs.workspace = true +basilisk-typeshed-fetch.workspace = true basilisk-profiler-protocol.workspace = true salsa.workspace = true @@ -49,7 +50,7 @@ futures-util = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "net", "time"] } serde_json = "1" tempfile = "3" -basilisk-test-utils = { workspace = true } +basilisk-test-utils = { workspace = true, features = ["checker"] } tower-service = "0.3" [lints] diff --git a/crates/basilisk-lsp/README.md b/crates/basilisk-lsp/README.md index d0863ec3..f9e3ddcc 100644 --- a/crates/basilisk-lsp/README.md +++ b/crates/basilisk-lsp/README.md @@ -13,25 +13,33 @@ Editor ⟷ [basilisk-lsp] ⟷ parser + resolver + checker ## Key concepts - **Full LSP** — not just a type checker. Provides completions, hover, go-to-definition, find references, rename, code actions, and inlay hints. -- **Incremental analysis** — integrates with `basilisk-db` (Salsa) for sub-10ms response times on edits. +- **Incremental analysis** — depends on `salsa` directly and drives the `BasiliskDatabase` re-exported by `basilisk-checker` (defined in `basilisk-db`), keeping one persistent database across the session so unchanged files are served from the memo ([CHKARCH-INCREMENTAL-SALSA](../../docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-INCREMENTAL-SALSA)). - **Integrated debugging** — spawns debugpy and brokers DAP connections so editors get F5-to-debug without separate extensions. - **Integrated profiling** — embeds py-spy for performance profiling with heatmap visualization. -- **Ruff integration** — delegates formatting and import organization to Ruff via subprocess. +- **Embedded Ruff formatter** — links the `ruff_python_formatter` crate into the binary and reimplements import hygiene natively on the Ruff AST. The `ruff` CLI is not a runtime dependency and is never spawned ([LSPFMT-ENGINE](../../docs/specs/LSP-FORMATTING-SPEC.md#LSPFMT-ENGINE), [LSPFMT-IMPORTS](../../docs/specs/LSP-FORMATTING-SPEC.md#LSPFMT-IMPORTS)). - **Code actions & refactoring** — extract function/variable, rename, move symbol, inline, and more. - **uv integration** — detects uv workspaces, parses lock files, and provides package intelligence. ## Dependencies +Principal direct dependencies, as declared in `Cargo.toml`: + | Crate | Purpose | |-------|---------| | `basilisk-parser` | Parsing | | `basilisk-resolver` | Name resolution | -| `basilisk-checker` | Type checking | +| `basilisk-checker` | Type checking; also re-exports `BasiliskDatabase` and the salsa inputs | | `basilisk-config` | Configuration | | `basilisk-stubs` | Type stubs | -| `basilisk-db` | Incremental computation | +| `basilisk-typeshed-fetch` | Typeshed acquisition | | `basilisk-uv` | uv package manager | +| `basilisk-profiler-protocol` | Profiler wire protocol | +| `salsa` | Incremental computation | | `tower-lsp` | LSP transport | +| `ruff_python_formatter` | In-process formatting engine | + +There is no direct dependency on `basilisk-db`; the Salsa database type it +defines reaches this crate through `basilisk-checker`'s re-export. ## Status diff --git a/crates/basilisk-lsp/src/code_actions/stubs.rs b/crates/basilisk-lsp/src/code_actions/stubs.rs index 763f49d0..8c8f87b6 100644 --- a/crates/basilisk-lsp/src/code_actions/stubs.rs +++ b/crates/basilisk-lsp/src/code_actions/stubs.rs @@ -1,4 +1,4 @@ -//! Implements [STUBRES-CREATE-LOCAL]. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#stubres-create-local +//! Implements [STUBRES-CREATE-LOCAL]. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CREATE-LOCAL //! //! Quick fix for BSK-0152 (missing type stubs): scaffold a local `.pyi` stub. //! diff --git a/crates/basilisk-lsp/src/completion/dot.rs b/crates/basilisk-lsp/src/completion/dot.rs index 8ebcaeed..70ab3f17 100644 --- a/crates/basilisk-lsp/src/completion/dot.rs +++ b/crates/basilisk-lsp/src/completion/dot.rs @@ -27,7 +27,7 @@ pub(super) fn dot_completions( } if receiver.as_deref() == Some("self") { - enclosing_class(resolved, byte_offset) + crate::hover::access::enclosing_class(resolved, byte_offset) .map(|c| class_member_items(c, &prefix)) .unwrap_or_default() } else if let Some(ref recv_name) = receiver { @@ -77,26 +77,6 @@ fn builtin_class_member_items( .collect() } -/// Find the innermost class that encloses `offset`. -/// -/// A `self.` expression always sits inside a method body, which always -/// follows its own `def` — so the enclosing class is the one owning the -/// NEAREST PRECEDING method start, not the first method in the file -/// (regression: members of the file's first class leaked into every later -/// class). -fn enclosing_class( - resolved: &basilisk_resolver::ResolvedModule, - offset: usize, -) -> Option<&basilisk_resolver::scope::ClassInfo> { - let func = resolved - .functions - .iter() - .filter(|f| f.class_name.is_some() && f.def_span.start_usize() <= offset) - .max_by_key(|f| f.def_span.start_usize())?; - let class_name = func.class_name.as_ref()?; - resolved.classes.iter().find(|c| &c.name == class_name) -} - /// Build completion items for all attributes and methods of `class`. fn class_member_items( class: &basilisk_resolver::scope::ClassInfo, diff --git a/crates/basilisk-lsp/src/config.rs b/crates/basilisk-lsp/src/config.rs index 5be6a4fd..e60c1c53 100644 --- a/crates/basilisk-lsp/src/config.rs +++ b/crates/basilisk-lsp/src/config.rs @@ -8,17 +8,26 @@ use std::path::{Path, PathBuf}; -/// Controls which files the LSP server analyses. +/// Controls which files the LSP server analyses ([ANALYSIS-MODES]). /// -/// See `docs/LSP-ANALYSIS-MODES-SPEC.md` for the full specification. +/// See `docs/specs/LSP-ANALYSIS-MODES-SPEC.md` for the full specification. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum AnalysisMode { - /// Analyse only files currently open in the editor. + /// Analyse only files currently open in the editor ([ANALYSIS-OPEN]). OpenFilesOnly, - /// Analyse all workspace Python files (default, strict-by-default). + /// Analyse all workspace Python files, publishing diagnostics for closed + /// files too (default, strict-by-default) ([ANALYSIS-WHOLE]). #[default] WholeModule, - /// Cross-file import graph analysis (reserved for future use). + /// `WholeModule` plus cross-file import-graph analysis ([ANALYSIS-CROSS]). + /// + /// Selects the cross-module salsa diagnostics query, so a file is checked + /// against the **current** content of the modules it imports + /// ([ANALYSIS-SYMBOLS-POP]); an edit to an open module's exports re-checks + /// that module's importers ([ANALYSIS-SYMBOLS-INVAL]); and the import graph + /// is built so navigation can answer "who imports this file?" + /// ([ANALYSIS-GRAPH]). The other modes check each file on its own, keeping + /// byte-for-byte `basilisk check` parity on what they report. CrossModule, } @@ -110,16 +119,12 @@ pub struct WorkspaceConfig { /// [STUBRES-CUSTOM-TYPESHED](../../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CUSTOM-TYPESHED)). /// `None` uses the bundled typeshed. pub typeshed_path: Option, - /// Exact full `python/typeshed` commit; unset selects Latest. + /// Exact full `python/typeshed` commit pin; unset resolves to the bundled + /// commit with an `UNPINNED` warning ([STUBRES-TYPESHED-CONFIG]). pub typeshed_commit: Option, - /// Optional `{sha}` archive mirror used only after a SHA is known. - pub typeshed_url: Option, - /// Optional directory for gate-accepted immutable archive cache entries. - pub typeshed_cache_path: Option, - /// Whether accepted archives may be reused during this run. - pub typeshed_cache: bool, - /// Whether consumed bytes must be bound to the selected Git tree. - pub typeshed_verify: bool, + /// Optional verified content-addressed store directory + /// ([STUBRES-TYPESHED-STORE]); unset uses the per-user OS default. + pub typeshed_store_path: Option, /// Redacted parse error for an explicitly malformed Typeshed key. /// Acquisition fails closed instead of silently applying a default. pub typeshed_configuration_error: Option, @@ -153,10 +158,7 @@ impl Default for WorkspaceConfig { stub_paths: Vec::new(), typeshed_path: None, typeshed_commit: None, - typeshed_url: None, - typeshed_cache_path: None, - typeshed_cache: true, - typeshed_verify: true, + typeshed_store_path: None, typeshed_configuration_error: None, formatter: FormatterEngine::Ruff, format_style: FormatStyle::default(), @@ -164,17 +166,21 @@ impl Default for WorkspaceConfig { } } -/// Build the config-free acquisition request shared by CLI, LSP, and MCP. +/// Build the config-free resolution request shared by CLI, LSP, and MCP. /// Invalid or mutually exclusive raw settings fail closed here, including when /// a file bypassed the configuration editor's stronger source validation. /// +/// There are exactly two sources ([STUBRES-TYPESHED]): a pinned commit or a +/// custom folder. An unset `typeshed-commit` resolves to the bundled commit as +/// an implicit pin (still `UNPINNED`); resolution never downloads. +/// /// # Errors /// -/// Returns a redacted, user-facing reason for an invalid source, pin, or -/// mirror template. The configured mirror value itself is never echoed. +/// Returns a redacted, user-facing reason for an invalid source or pin. pub fn typeshed_request( config: &WorkspaceConfig, ) -> Result { + use basilisk_stubs::typeshed::bundle::bundled_commit_sha; use basilisk_stubs::typeshed::gittree::Oid; use basilisk_stubs::typeshed::source::{SourceSelection, TypeshedRequest}; @@ -185,13 +191,6 @@ pub fn typeshed_request( if config.typeshed_path.is_some() && config.typeshed_commit.is_some() { return Err("typeshed-path and typeshed-commit are mutually exclusive".to_owned()); } - if config - .typeshed_url - .as_deref() - .is_some_and(|url| !basilisk_config::is_valid_typeshed_url_template(url)) - { - return Err("typeshed-url must be HTTPS with exactly one {sha} placeholder".to_owned()); - } let selection = if let Some(path) = config.typeshed_path.as_deref() { SourceSelection::Custom { path: path @@ -200,19 +199,22 @@ pub fn typeshed_request( .to_owned(), } } else if let Some(commit) = config.typeshed_commit.as_deref() { - SourceSelection::ExactCommit { + SourceSelection::Pinned { commit: Oid::from_hex(commit).map_err(|_invalid_oid| { "typeshed-commit must be a full 40-character hex SHA".to_owned() })?, + explicit: true, } } else { - SourceSelection::Latest + SourceSelection::Pinned { + commit: Oid::from_hex(bundled_commit_sha()) + .map_err(|_invalid_oid| "bundled typeshed identity is unreadable".to_owned())?, + explicit: false, + } }; Ok(TypeshedRequest { selection, - verify_content: config.typeshed_verify, - use_cache: config.typeshed_cache, - url_template: config.typeshed_url.clone(), + store_path: config.typeshed_store_path.clone(), }) } @@ -252,8 +254,8 @@ pub fn load_analysis_config(root: &Path) -> WorkspaceConfig { cfg.typeshed_path = cfg .typeshed_path .map(|p| if p.is_absolute() { p } else { root.join(p) }); - cfg.typeshed_cache_path = - cfg.typeshed_cache_path + cfg.typeshed_store_path = + cfg.typeshed_store_path .map(|p| if p.is_absolute() { p } else { root.join(p) }); cfg } @@ -404,32 +406,11 @@ fn load_json_config(path: &Path) -> Option { cfg.typeshed_commit = Some(v.to_owned()); } if let Some(v) = obj - .get("typeshedUrl") - .or_else(|| obj.get("typeshed-url")) + .get("typeshedStorePath") + .or_else(|| obj.get("typeshed-store-path")) .and_then(|v| v.as_str()) { - cfg.typeshed_url = Some(v.to_owned()); - } - if let Some(v) = obj - .get("typeshedCachePath") - .or_else(|| obj.get("typeshed-cache-path")) - .and_then(|v| v.as_str()) - { - cfg.typeshed_cache_path = Some(PathBuf::from(v)); - } - if let Some(v) = obj - .get("typeshedCache") - .or_else(|| obj.get("typeshed-cache")) - .and_then(serde_json::Value::as_bool) - { - cfg.typeshed_cache = v; - } - if let Some(v) = obj - .get("typeshedVerify") - .or_else(|| obj.get("typeshed-verify")) - .and_then(serde_json::Value::as_bool) - { - cfg.typeshed_verify = v; + cfg.typeshed_store_path = Some(PathBuf::from(v)); } Some(cfg) @@ -506,17 +487,8 @@ fn workspace_config_from_toml(section: &toml::Table) -> WorkspaceConfig { if let Some(v) = toml_str(section, &["typeshed-commit", "typeshedCommit"]) { cfg.typeshed_commit = Some(v.to_owned()); } - if let Some(v) = toml_str(section, &["typeshed-url", "typeshedUrl"]) { - cfg.typeshed_url = Some(v.to_owned()); - } - if let Some(v) = toml_str(section, &["typeshed-cache-path", "typeshedCachePath"]) { - cfg.typeshed_cache_path = Some(PathBuf::from(v)); - } - if let Some(v) = toml_bool(section, &["typeshed-cache", "typeshedCache"]) { - cfg.typeshed_cache = v; - } - if let Some(v) = toml_bool(section, &["typeshed-verify", "typeshedVerify"]) { - cfg.typeshed_verify = v; + if let Some(v) = toml_str(section, &["typeshed-store-path", "typeshedStorePath"]) { + cfg.typeshed_store_path = Some(PathBuf::from(v)); } cfg } @@ -527,66 +499,28 @@ fn toml_str<'a>(table: &'a toml::Table, keys: &[&str]) -> Option<&'a str> { .find_map(|key| table.get(*key).and_then(toml::Value::as_str)) } -/// First boolean value found among the given key spellings. -fn toml_bool(table: &toml::Table, keys: &[&str]) -> Option { - keys.iter() - .find_map(|key| table.get(*key).and_then(toml::Value::as_bool)) -} +/// Every typeshed key holds a string ([STUBRES-TYPESHED-CONFIG]). +const TYPESHED_STRING_KEYS: [&str; 6] = [ + "typeshed-path", + "typeshedPath", + "typeshed-commit", + "typeshedCommit", + "typeshed-store-path", + "typeshedStorePath", +]; fn toml_typeshed_type_error(table: &toml::Table) -> Option { - for key in [ - "typeshed-path", - "typeshedPath", - "typeshed-commit", - "typeshedCommit", - "typeshed-url", - "typeshedUrl", - "typeshed-cache-path", - "typeshedCachePath", - ] { - if table.get(key).is_some_and(|value| !value.is_str()) { - return Some(format!("{key} must be a string")); - } - } - for key in [ - "typeshed-cache", - "typeshedCache", - "typeshed-verify", - "typeshedVerify", - ] { - if table.get(key).is_some_and(|value| !value.is_bool()) { - return Some(format!("{key} must be a boolean")); - } - } - None + TYPESHED_STRING_KEYS + .iter() + .find(|key| table.get(**key).is_some_and(|value| !value.is_str())) + .map(|key| format!("{key} must be a string")) } fn json_typeshed_type_error(object: &serde_json::Map) -> Option { - for key in [ - "typeshed-path", - "typeshedPath", - "typeshed-commit", - "typeshedCommit", - "typeshed-url", - "typeshedUrl", - "typeshed-cache-path", - "typeshedCachePath", - ] { - if object.get(key).is_some_and(|value| !value.is_string()) { - return Some(format!("{key} must be a string")); - } - } - for key in [ - "typeshed-cache", - "typeshedCache", - "typeshed-verify", - "typeshedVerify", - ] { - if object.get(key).is_some_and(|value| !value.is_boolean()) { - return Some(format!("{key} must be a boolean")); - } - } - None + TYPESHED_STRING_KEYS + .iter() + .find(|key| object.get(**key).is_some_and(|value| !value.is_string())) + .map(|key| format!("{key} must be a string")) } /// First array value found among the given key spellings, as `PathBuf`s. @@ -633,7 +567,7 @@ mod tests { assert!(cfg.typeshed_path.is_none()); } - /// [STUBRES-TYPESHED-CONFIG]: the LSP consumes the same complete source + /// [STUBRES-TYPESHED-CONFIG]: the LSP consumes the same three-key source /// policy as the CLI/config crate; path fields are rooted at the workspace. #[test] fn test_load_runtime_typeshed_policy_from_pyproject() { @@ -644,10 +578,7 @@ mod tests { concat!( "[tool.basilisk]\n", "typeshed-commit = \"83c2518a9e6abbda0c44592c3483de459198f887\"\n", - "typeshed-url = \"https://mirror.invalid/{sha}.zip\"\n", - "typeshed-cache-path = \".cache/typeshed\"\n", - "typeshed-cache = false\n", - "typeshed-verify = false\n", + "typeshed-store-path = \".cache/typeshed-store\"\n", ), ) .unwrap(); @@ -658,12 +589,9 @@ mod tests { Some("83c2518a9e6abbda0c44592c3483de459198f887") ); assert_eq!( - cfg.typeshed_url.as_deref(), - Some("https://mirror.invalid/{sha}.zip") + cfg.typeshed_store_path, + Some(dir.join(".cache/typeshed-store")) ); - assert_eq!(cfg.typeshed_cache_path, Some(dir.join(".cache/typeshed"))); - assert!(!cfg.typeshed_cache); - assert!(!cfg.typeshed_verify); assert_eq!(cfg.python_version, None); let _ = std::fs::remove_dir_all(&dir); @@ -881,7 +809,7 @@ mod tests { } #[test] - fn typeshed_request_rejects_raw_source_conflict_and_bad_mirror() { + fn typeshed_request_rejects_raw_source_conflict() { let conflict = WorkspaceConfig { typeshed_path: Some(PathBuf::from("custom")), typeshed_commit: Some("83c2518a9e6abbda0c44592c3483de459198f887".to_owned()), @@ -890,28 +818,16 @@ mod tests { assert!(typeshed_request(&conflict) .expect_err("conflicting source settings must fail closed") .contains("mutually exclusive")); - - let mirror = WorkspaceConfig { - typeshed_url: Some("http://secret.invalid/{sha}.zip".to_owned()), - ..WorkspaceConfig::default() - }; - let error = typeshed_request(&mirror).expect_err("HTTP mirror must fail closed"); - assert!(error.contains("HTTPS")); - assert!( - !error.contains("secret.invalid"), - "mirror URL must be redacted" - ); } - /// [STUBRES-TYPESHED-CONFIG]: an explicitly malformed acquisition key - /// cannot disappear into a default Latest/verified/cache-on request. + /// [STUBRES-TYPESHED-CONFIG]: an explicitly malformed typeshed key cannot + /// disappear into the default bundled-pin request. #[test] fn malformed_typeshed_setting_types_fail_closed() { for source in [ "typeshed-commit = 42", "typeshed-path = false", - "typeshed-cache = \"false\"", - "typeshed-verify = \"true\"", + "typeshed-store-path = false", ] { let table: toml::Table = source.parse().expect("fixture TOML"); let config = workspace_config_from_toml(&table); @@ -924,8 +840,7 @@ mod tests { for (key, value) in [ ("typeshedCommit", serde_json::json!(42)), ("typeshedPath", serde_json::json!(false)), - ("typeshedCache", serde_json::json!("false")), - ("typeshedVerify", serde_json::json!("true")), + ("typeshedStorePath", serde_json::json!(false)), ] { let mut object = serde_json::Map::new(); let _ = object.insert(key.to_owned(), value); @@ -940,21 +855,39 @@ mod tests { } } + /// [STUBRES-TYPESHED]: an explicit pin is an explicit `Pinned` selection; + /// unset keys resolve to the bundled commit as an implicit pin — the + /// request has no download, cache, or verification knobs at all. #[test] - fn typeshed_request_preserves_exact_policy_controls() { + fn typeshed_request_resolves_explicit_and_default_pins() { + use basilisk_stubs::typeshed::source::SourceSelection; + let config = WorkspaceConfig { typeshed_commit: Some("83C2518A9E6ABBDA0C44592C3483DE459198F887".to_owned()), - typeshed_url: Some("https://mirror.invalid/{sha}.zip".to_owned()), - typeshed_cache: false, - typeshed_verify: false, + typeshed_store_path: Some(PathBuf::from("/stores/typeshed")), ..WorkspaceConfig::default() }; let request = typeshed_request(&config).expect("valid exact request"); assert!(matches!( request.selection, - basilisk_stubs::typeshed::source::SourceSelection::ExactCommit { .. } + SourceSelection::Pinned { explicit: true, .. } )); - assert!(!request.use_cache); - assert!(!request.verify_content); + assert_eq!(request.store_path, Some(PathBuf::from("/stores/typeshed"))); + + let default_request = + typeshed_request(&WorkspaceConfig::default()).expect("default request"); + match default_request.selection { + SourceSelection::Pinned { commit, explicit } => { + assert!(!explicit, "an unset pin must stay UNPINNED"); + assert_eq!( + commit.to_hex(), + basilisk_stubs::typeshed::bundle::bundled_commit_sha() + ); + } + SourceSelection::Custom { .. } => { + unreachable!("an unset config must resolve to the bundled pin") + } + } + assert_eq!(default_request.store_path, None); } } diff --git a/crates/basilisk-lsp/src/configuration_editor/mod.rs b/crates/basilisk-lsp/src/configuration_editor/mod.rs index 041a7dab..8332ebbd 100644 --- a/crates/basilisk-lsp/src/configuration_editor/mod.rs +++ b/crates/basilisk-lsp/src/configuration_editor/mod.rs @@ -10,14 +10,16 @@ mod snapshot; pub(crate) mod snapshot_typeshed; mod state; mod transaction; -mod typeshed_acquisition; +mod typeshed_resolution; mod watch; +pub(crate) use protocol::rpc_error; pub(crate) use state::ConfigurationEditorState; pub(crate) use transaction::{ - apply_rule_updates, configuration_document, refresh_after_configuration_change, - ConfigurationRefreshHandles, + apply_configuration_update, apply_rule_updates, configuration_document, + refresh_after_configuration_change, ConfigurationRefreshHandles, }; +pub(crate) use typeshed_resolution::{resolve_and_activate, resolve_workspace}; pub(crate) use watch::{ refresh_environment_from_disk, refresh_root_from_disk, spawn_configuration_watcher, }; diff --git a/crates/basilisk-lsp/src/configuration_editor/model.rs b/crates/basilisk-lsp/src/configuration_editor/model.rs index bbae4e35..6c9a4563 100644 --- a/crates/basilisk-lsp/src/configuration_editor/model.rs +++ b/crates/basilisk-lsp/src/configuration_editor/model.rs @@ -40,24 +40,14 @@ pub enum TagKind { Descriptive, } -/// Closed allowlist of runtime Typeshed configuration keys. +/// Closed allowlist of runtime Typeshed configuration keys — the whole +/// surface is three keys, every one a string ([LSPCFGED-TYPESHED]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum TypeshedSettingKey { TypeshedPath, TypeshedCommit, - TypeshedUrl, - TypeshedCachePath, - TypeshedCache, - TypeshedVerify, -} - -/// Typed values accepted by Typeshed setting mutations. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind")] -pub enum TypeshedSettingValue { - Text { value: String }, - Boolean { value: bool }, + TypeshedStorePath, } /// The six operations the editor may ask the configuration service to apply. @@ -80,7 +70,7 @@ pub enum EditorMutation { }, SetTypeshedSetting { key: TypeshedSettingKey, - value: TypeshedSettingValue, + value: String, }, RemoveTypeshedSetting { key: TypeshedSettingKey, diff --git a/crates/basilisk-lsp/src/configuration_editor/model_typeshed.rs b/crates/basilisk-lsp/src/configuration_editor/model_typeshed.rs index a2a386bd..f554d680 100644 --- a/crates/basilisk-lsp/src/configuration_editor/model_typeshed.rs +++ b/crates/basilisk-lsp/src/configuration_editor/model_typeshed.rs @@ -2,89 +2,59 @@ use serde::{Deserialize, Serialize}; -use super::{ - ConfigurationPreview, ConfigurationSnapshot, Revision, TypeshedSettingKey, - TypeshedSettingValue, Uri, -}; - -/// The active source and the value that defines it. A pinned commit and a -/// custom folder cannot coexist, and `Latest` cannot carry a pin — the wire -/// model makes those states unrepresentable ([LSPCFGED-TYPESHED]). +use super::{ConfigurationSnapshot, Revision, TypeshedSettingKey, Uri}; + +/// The active source and the value that defines it. There are exactly two +/// sources ([LSPCFGED-TYPESHED]): a pinned commit or a custom folder. There is +/// no "track latest" source — freshness is the user-invoked download action. +/// An unset pin reports the bundled default commit (still `UNPINNED`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all_fields = "camelCase")] pub enum TypeshedSource { - Latest, ExactCommit { commit: String }, CustomFolder { path: String }, } -/// Download policy of a downloaded source. A user-managed folder downloads -/// nothing, so it has none at all. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TypeshedDownloadPolicy { - pub reuse_downloads: bool, - pub verify_content: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub archive_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_folder: Option, -} - +/// Downloading is the only long-running state, and it is always user-invoked +/// ([LSPCFGED-TYPESHED-DOWNLOAD]). `NoSource` = the selected source is not on +/// this machine, so analysis does not run. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum TypeshedLifecycle { - Acquiring, + Downloading, Ready, - Blocked, + NoSource, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum TypeshedAction { - PinCurrent, - AcquireFresh, + DownloadLatest, + DownloadPinned, ViewLicense, } +/// The active source is the whole trust story (custom = user-managed, bundled +/// = build-vetted, exact commit = attested at download, re-proven offline), so +/// there are no separate transport or provenance fields +/// ([STUBRES-TYPESHED-WARN]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum TypeshedActiveSource { Custom, ExactCommit, - Latest, Bundled, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind")] -pub enum TypeshedTransport { - CustomPath, - EmbeddedZip, - Codeload, - Mirror, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum TypeshedLicenseStatus { - Acquiring, Unavailable, Approved, Changed, NotSupplied, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind")] -pub enum TypeshedProvenance { - Pending, - GithubTlsAttested, - Unverified, - BundleVetted, - UserManaged, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum TypeshedWarningSeverity { @@ -105,31 +75,25 @@ pub struct TypeshedWarningState { pub struct TypeshedStatusState { pub lifecycle: TypeshedLifecycle, #[serde(skip_serializing_if = "Option::is_none")] - pub blocked_reason: Option, + pub no_source_reason: Option, #[serde(skip_serializing_if = "Option::is_none")] pub active_source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub commit_identity: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub transport: Option, pub license_status: TypeshedLicenseStatus, - pub provenance: TypeshedProvenance, - pub signed_release: bool, pub warnings: Vec, } +/// Everything the editor needs, and nothing it can misrender: the one active +/// source, the store folder pins resolve from (none for a custom folder), and +/// whether a license document exists to open. Labels are client copy, not +/// server state. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -/// Everything the editor needs and nothing it can misrender: the one active -/// source, the download policy that source has, the commit -/// [`TypeshedAction::PinCurrent`] would write when pinning is possible, and -/// whether a license document exists to open. Labels are client copy. pub struct TypeshedConfigurationState { pub source: TypeshedSource, #[serde(skip_serializing_if = "Option::is_none")] - pub downloads: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub pinnable_commit: Option, + pub store_folder: Option, pub license_available: bool, pub status: TypeshedStatusState, } @@ -139,9 +103,9 @@ pub struct TypeshedConfigurationState { pub struct TypeshedSettingChange { pub key: TypeshedSettingKey, #[serde(skip_serializing_if = "Option::is_none")] - pub before: Option, + pub before: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub after: Option, + pub after: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -169,7 +133,6 @@ pub struct TypeshedLicenseDocument { )] #[serde(tag = "kind")] pub enum TypeshedActionResult { - Preview { preview: ConfigurationPreview }, Snapshot { snapshot: ConfigurationSnapshot }, License { license: TypeshedLicenseDocument }, } diff --git a/crates/basilisk-lsp/src/configuration_editor/mutation.rs b/crates/basilisk-lsp/src/configuration_editor/mutation.rs index 432d4fac..767c4472 100644 --- a/crates/basilisk-lsp/src/configuration_editor/mutation.rs +++ b/crates/basilisk-lsp/src/configuration_editor/mutation.rs @@ -8,15 +8,13 @@ use std::collections::HashSet; -use basilisk_config::{ - BasiliskConfig, ConfigDocument, ConfigurationUpdate, TypeshedConfigKey, TypeshedConfigValue, -}; +use basilisk_config::{BasiliskConfig, ConfigDocument, ConfigurationUpdate, TypeshedConfigKey}; use tower_lsp::jsonrpc::{Error, Result as LspResult}; use super::catalog::{descriptors, effective_severity, wire_to_config, SelectionError}; use super::model::{ ConfigurationImpact, EditorMutation, ResolvedRuleChange, RuleDescriptor, RuleSeverity, - TypeshedSettingChange, TypeshedSettingKey, TypeshedSettingValue, + TypeshedSettingChange, TypeshedSettingKey, }; use super::protocol::{path_uri, rpc_error, rpc_error_data}; use super::snapshot::{count_i64, Inventory}; @@ -107,48 +105,27 @@ fn typeshed_config_key(key: TypeshedSettingKey) -> TypeshedConfigKey { match key { TypeshedSettingKey::TypeshedPath => TypeshedConfigKey::TypeshedPath, TypeshedSettingKey::TypeshedCommit => TypeshedConfigKey::TypeshedCommit, - TypeshedSettingKey::TypeshedUrl => TypeshedConfigKey::TypeshedUrl, - TypeshedSettingKey::TypeshedCachePath => TypeshedConfigKey::TypeshedCachePath, - TypeshedSettingKey::TypeshedCache => TypeshedConfigKey::TypeshedCache, - TypeshedSettingKey::TypeshedVerify => TypeshedConfigKey::TypeshedVerify, + TypeshedSettingKey::TypeshedStorePath => TypeshedConfigKey::TypeshedStorePath, } } -fn validate_typeshed_value( - key: TypeshedSettingKey, - value: &TypeshedSettingValue, -) -> LspResult { - match (key, value) { - (TypeshedSettingKey::TypeshedCommit, TypeshedSettingValue::Text { value }) - if basilisk_config::is_full_commit_sha(value) => - { - Ok(TypeshedConfigValue::Text(value.clone())) +fn validate_typeshed_value(key: TypeshedSettingKey, value: &str) -> LspResult { + match key { + TypeshedSettingKey::TypeshedCommit if basilisk_config::is_full_commit_sha(value) => { + Ok(value.to_owned()) } - (TypeshedSettingKey::TypeshedCommit, _) => Err(invalid_typeshed_setting( + TypeshedSettingKey::TypeshedCommit => Err(invalid_typeshed_setting( key, "typeshed-commit must be a full 40-character hexadecimal SHA", )), - (TypeshedSettingKey::TypeshedUrl, TypeshedSettingValue::Text { value }) - if basilisk_config::is_valid_typeshed_url_template(value) => + TypeshedSettingKey::TypeshedPath | TypeshedSettingKey::TypeshedStorePath + if !value.trim().is_empty() => { - Ok(TypeshedConfigValue::Text(value.clone())) + Ok(value.to_owned()) } - (TypeshedSettingKey::TypeshedUrl, _) => Err(invalid_typeshed_setting( - key, - "typeshed-url must be HTTPS with exactly one {sha} placeholder", - )), - ( - TypeshedSettingKey::TypeshedPath | TypeshedSettingKey::TypeshedCachePath, - TypeshedSettingValue::Text { value }, - ) if !value.trim().is_empty() => Ok(TypeshedConfigValue::Text(value.clone())), - ( - TypeshedSettingKey::TypeshedCache | TypeshedSettingKey::TypeshedVerify, - TypeshedSettingValue::Boolean { value }, - ) => Ok(TypeshedConfigValue::Boolean(*value)), - _ => Err(invalid_typeshed_setting( - key, - "Typeshed setting has the wrong value type or an empty value", - )), + TypeshedSettingKey::TypeshedPath | TypeshedSettingKey::TypeshedStorePath => Err( + invalid_typeshed_setting(key, "Typeshed setting requires a non-empty path"), + ), } } @@ -254,41 +231,17 @@ pub(super) fn resolved_changes( .collect() } -fn config_typeshed_value( - config: &BasiliskConfig, - key: TypeshedSettingKey, -) -> Option { +fn config_typeshed_value(config: &BasiliskConfig, key: TypeshedSettingKey) -> Option { match key { - TypeshedSettingKey::TypeshedPath => { - config - .typeshed_path - .as_ref() - .map(|path| TypeshedSettingValue::Text { - value: path.to_string_lossy().into_owned(), - }) - } - TypeshedSettingKey::TypeshedCommit => config - .typeshed_commit - .clone() - .map(|value| TypeshedSettingValue::Text { value }), - TypeshedSettingKey::TypeshedUrl => config - .typeshed_url - .clone() - .map(|value| TypeshedSettingValue::Text { value }), - TypeshedSettingKey::TypeshedCachePath => { - config - .typeshed_cache_path - .as_ref() - .map(|path| TypeshedSettingValue::Text { - value: path.to_string_lossy().into_owned(), - }) - } - TypeshedSettingKey::TypeshedCache => config - .typeshed_cache - .map(|value| TypeshedSettingValue::Boolean { value }), - TypeshedSettingKey::TypeshedVerify => config - .typeshed_verify - .map(|value| TypeshedSettingValue::Boolean { value }), + TypeshedSettingKey::TypeshedPath => config + .typeshed_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + TypeshedSettingKey::TypeshedCommit => config.typeshed_commit.clone(), + TypeshedSettingKey::TypeshedStorePath => config + .typeshed_store_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), } } @@ -300,10 +253,7 @@ pub(super) fn resolved_typeshed_changes( [ TypeshedSettingKey::TypeshedPath, TypeshedSettingKey::TypeshedCommit, - TypeshedSettingKey::TypeshedUrl, - TypeshedSettingKey::TypeshedCachePath, - TypeshedSettingKey::TypeshedCache, - TypeshedSettingKey::TypeshedVerify, + TypeshedSettingKey::TypeshedStorePath, ] .into_iter() .filter_map(|key| { diff --git a/crates/basilisk-lsp/src/configuration_editor/mutation_tests.rs b/crates/basilisk-lsp/src/configuration_editor/mutation_tests.rs index 248e7e56..363eee1a 100644 --- a/crates/basilisk-lsp/src/configuration_editor/mutation_tests.rs +++ b/crates/basilisk-lsp/src/configuration_editor/mutation_tests.rs @@ -11,7 +11,6 @@ use std::path::PathBuf; use basilisk_config::{ build_configuration_patch, BasiliskConfig, ConfigDocument, RuleSeverity, TypeshedConfigKey, - TypeshedConfigValue, }; use super::{ @@ -21,7 +20,7 @@ use super::{ }; use crate::configuration_editor::catalog::{descriptors, SelectionError}; use crate::configuration_editor::model::{ - EditorMutation, RuleSeverity as WireSeverity, TypeshedSettingKey, TypeshedSettingValue, + EditorMutation, RuleSeverity as WireSeverity, TypeshedSettingKey, }; use crate::configuration_editor::snapshot::Inventory; @@ -89,11 +88,11 @@ fn build_update_folds_all_six_mutations_into_entry_updates() { tag: "suppressions".to_owned(), }, EditorMutation::SetTypeshedSetting { - key: TypeshedSettingKey::TypeshedCache, - value: TypeshedSettingValue::Boolean { value: false }, + key: TypeshedSettingKey::TypeshedStorePath, + value: "stores/typeshed".to_owned(), }, EditorMutation::RemoveTypeshedSetting { - key: TypeshedSettingKey::TypeshedUrl, + key: TypeshedSettingKey::TypeshedPath, }, ], &catalog, @@ -115,11 +114,14 @@ fn build_update_folds_all_six_mutations_into_entry_updates() { update .typeshed .entries - .get(&TypeshedConfigKey::TypeshedCache), - Some(&Some(TypeshedConfigValue::Boolean(false))) + .get(&TypeshedConfigKey::TypeshedStorePath), + Some(&Some("stores/typeshed".to_owned())) ); assert_eq!( - update.typeshed.entries.get(&TypeshedConfigKey::TypeshedUrl), + update + .typeshed + .entries + .get(&TypeshedConfigKey::TypeshedPath), Some(&None) ); } @@ -216,29 +218,23 @@ fn pep_disable_mutations_are_rejected() { ); } -/// [LSPCFGED-TYPESHED]: Typeshed settings reject wrong scalar types, -/// malformed pins, and non-HTTPS/multi-placeholder mirrors. +/// [LSPCFGED-TYPESHED]: the whole surface is three string keys — malformed +/// pins and blank paths are request errors, and a full SHA passes. #[test] fn typeshed_setting_values_are_strictly_validated() { let catalog = descriptors(); for mutation in [ EditorMutation::SetTypeshedSetting { key: TypeshedSettingKey::TypeshedCommit, - value: TypeshedSettingValue::Text { - value: "short".to_owned(), - }, + value: "short".to_owned(), }, EditorMutation::SetTypeshedSetting { - key: TypeshedSettingKey::TypeshedUrl, - value: TypeshedSettingValue::Text { - value: "http://mirror.invalid/{sha}/{sha}.zip".to_owned(), - }, + key: TypeshedSettingKey::TypeshedPath, + value: " ".to_owned(), }, EditorMutation::SetTypeshedSetting { - key: TypeshedSettingKey::TypeshedVerify, - value: TypeshedSettingValue::Text { - value: "false".to_owned(), - }, + key: TypeshedSettingKey::TypeshedStorePath, + value: String::new(), }, ] { let result = build_update(&[mutation], &catalog); @@ -249,6 +245,14 @@ fn typeshed_setting_values_are_strictly_validated() { Some(serde_json::json!("invalidTypeshedSetting")) ); } + assert!(build_update( + &[EditorMutation::SetTypeshedSetting { + key: TypeshedSettingKey::TypeshedCommit, + value: "83c2518a9e6abbda0c44592c3483de459198f887".to_owned(), + }], + &catalog, + ) + .is_ok()); } /// [LSPCFGED-TYPESHED]: custom and exact sources cannot coexist, and custom @@ -317,10 +321,7 @@ fn resolved_typeshed_changes_cover_the_closed_setting_allowlist() { let after = BasiliskConfig { typeshed_path: Some(PathBuf::from("custom-typeshed")), typeshed_commit: Some("83c2518a9e6abbda0c44592c3483de459198f887".to_owned()), - typeshed_url: Some("https://mirror.example/{sha}.zip".to_owned()), - typeshed_cache_path: Some(PathBuf::from(".typeshed-cache")), - typeshed_cache: Some(false), - typeshed_verify: Some(false), + typeshed_store_path: Some(PathBuf::from("stores/typeshed")), ..BasiliskConfig::default() }; @@ -331,10 +332,7 @@ fn resolved_typeshed_changes_cover_the_closed_setting_allowlist() { vec![ TypeshedSettingKey::TypeshedPath, TypeshedSettingKey::TypeshedCommit, - TypeshedSettingKey::TypeshedUrl, - TypeshedSettingKey::TypeshedCachePath, - TypeshedSettingKey::TypeshedCache, - TypeshedSettingKey::TypeshedVerify, + TypeshedSettingKey::TypeshedStorePath, ] ); assert!(changes.iter().all(|change| change.before.is_none())); diff --git a/crates/basilisk-lsp/src/configuration_editor/protocol.rs b/crates/basilisk-lsp/src/configuration_editor/protocol.rs index 1f27e045..18abd61e 100644 --- a/crates/basilisk-lsp/src/configuration_editor/protocol.rs +++ b/crates/basilisk-lsp/src/configuration_editor/protocol.rs @@ -12,10 +12,9 @@ use tower_lsp::lsp_types::Url; use super::catalog::{descriptors, expand_selector}; use super::model::{ - ApplyConfigurationRequest, ConfigurationPreview, ConfigurationSnapshot, EditorMutation, + ApplyConfigurationRequest, ConfigurationPreview, ConfigurationSnapshot, PreviewConfigurationRequest, RuleOccurrencesRequest, RuleOccurrencesResponse, TypeshedAction, - TypeshedActionRequest, TypeshedActionResult, TypeshedLicenseDocument, TypeshedSettingKey, - TypeshedSettingValue, + TypeshedActionRequest, TypeshedActionResult, TypeshedLicenseDocument, }; use super::mutation::{ build_impact, build_update, require_mutations, require_no_pep_disable, require_revision, @@ -175,7 +174,10 @@ impl LspServer { )) } - /// Handle the closed Typeshed action union ([LSPCFGED-TYPESHED]). + /// Handle the closed Typeshed action union ([LSPCFGED-TYPESHED]). The two + /// download actions are the ONLY paths that reach the network, and both + /// live outside the configuration editor ([TYPESHEDRT-SEGREGATION]); + /// their finished state is returned as a fresh snapshot. pub(crate) async fn typeshed_action( &self, request: TypeshedActionRequest, @@ -187,115 +189,20 @@ impl LspServer { .map_err(config_error)?; require_revision(&document, &request.base_revision)?; match request.action { - TypeshedAction::PinCurrent => self.pin_current_action(&root, &document, request).await, - TypeshedAction::AcquireFresh => self.acquire_fresh_action(&root, &document).await, - TypeshedAction::ViewLicense => self.view_license_action(&root, &document).await, - } - } - - async fn pin_current_action( - &self, - root: &Path, - document: &ConfigDocument, - request: TypeshedActionRequest, - ) -> LspResult { - if document.config.typeshed_path.is_some() { - return Err(rpc_error( - "typeshedActionUnavailable", - "a custom Typeshed folder has no upstream commit to pin", - )); - } - let commit = self - .typeshed_generations - .read() - .await - .get(root) - .and_then(crate::server::typeshed_status::TypeshedGeneration::ready_snapshot) - .filter(|snapshot| snapshot_matches_document(snapshot, document)) - .and_then(|snapshot| snapshot.status.commit) - .ok_or_else(|| { - rpc_error( - "typeshedActionUnavailable", - "the active Typeshed source has no commit to pin", - ) - })? - .to_hex(); - let preview = self - .preview_configuration_change(PreviewConfigurationRequest { - root_uri: request.root_uri, - base_revision: request.base_revision, - mutations: vec![ - EditorMutation::SetTypeshedSetting { - key: TypeshedSettingKey::TypeshedCommit, - value: TypeshedSettingValue::Text { value: commit }, - }, - EditorMutation::RemoveTypeshedSetting { - key: TypeshedSettingKey::TypeshedPath, - }, - ], - }) - .await?; - Ok(TypeshedActionResult::Preview { preview }) - } - - async fn acquire_fresh_action( - &self, - root: &Path, - document: &ConfigDocument, - ) -> LspResult { - let staged = match super::typeshed_acquisition::acquire_fresh(self, root, &document.config) - .await - { - Ok(staged) => staged, - Err(error) => { - let rpc_error = error.rpc_error(); - let Some(failure) = error.into_failure() else { - return Err(rpc_error); - }; - let handles = self.refresh_handles(); - let refresh = super::transaction::refresh_with_document( - &handles, - root, - "typeshedAcquireFreshFailed", - document, - ) - .await; - super::typeshed_acquisition::publish_failure(&handles, root, failure).await; - if let Err(error) = refresh { - tracing::warn!(root = %root.display(), error = %error, "failed to clear analysis after Typeshed refresh failure"); - } - return Err(rpc_error); + TypeshedAction::DownloadLatest => { + crate::typeshed_download::download_latest_and_pin(self, &root, &document).await?; + Ok(TypeshedActionResult::Snapshot { + snapshot: snapshot_for_root(self, &root).await?, + }) } - }; - let handles = self.refresh_handles(); - let refresh = super::transaction::refresh_with_document_and_typeshed( - &handles, - root, - "typeshedAcquireFresh", - document, - Some(staged.candidate()), - ) - .await; - if let Err(error) = refresh { - let cleanup = super::transaction::refresh_with_document( - &handles, - root, - "typeshedAcquireFreshBlocked", - document, - ) - .await; - staged - .block(self, root, "configuration refresh failed") - .await; - if let Err(cleanup_error) = cleanup { - tracing::warn!(root = %root.display(), error = %cleanup_error, "failed to clear analysis after Typeshed activation failure"); + TypeshedAction::DownloadPinned => { + crate::typeshed_download::download_pinned(self, &root, &document).await?; + Ok(TypeshedActionResult::Snapshot { + snapshot: snapshot_for_root(self, &root).await?, + }) } - return Err(error); + TypeshedAction::ViewLicense => self.view_license_action(&root, &document).await, } - staged.activate(self, root).await; - Ok(TypeshedActionResult::Snapshot { - snapshot: snapshot_with_document(self, root, document).await?, - }) } async fn view_license_action( @@ -321,7 +228,7 @@ impl LspServer { .ok_or_else(|| { rpc_error( "typeshedActionUnavailable", - "Typeshed acquisition for this workspace has not reached a terminal source", + "the selected Typeshed source is not on this machine, so no license document is available", ) })?; let content = snapshot @@ -348,17 +255,17 @@ fn snapshot_matches_document( return snapshot.status.active_source == basilisk_stubs::typeshed::source::SourceKind::Custom; } - match document.config.typeshed_commit.as_deref() { - Some(commit) => snapshot - .status - .commit - .is_some_and(|oid| oid.to_hex() == commit), - None => matches!( - snapshot.status.active_source, - basilisk_stubs::typeshed::source::SourceKind::Latest - | basilisk_stubs::typeshed::source::SourceKind::Bundled - ), - } + // An unset pin IS the bundled commit ([STUBRES-TYPESHED]), so the active + // snapshot matches when it carries the effective SHA. + let effective_commit = document + .config + .typeshed_commit + .clone() + .unwrap_or_else(|| basilisk_stubs::typeshed::bundle::bundled_commit_sha().to_owned()); + snapshot + .status + .commit + .is_some_and(|oid| oid.to_hex() == effective_commit) } async fn snapshot_for_root(server: &LspServer, root: &Path) -> LspResult { @@ -430,7 +337,7 @@ pub(super) fn config_error(error: ConfigDocumentError) -> Error { ) } -pub(super) fn rpc_error(kind: &str, message: &str) -> Error { +pub(crate) fn rpc_error(kind: &str, message: &str) -> Error { rpc_error_data(kind, message, serde_json::json!({})) } diff --git a/crates/basilisk-lsp/src/configuration_editor/protocol_tests.rs b/crates/basilisk-lsp/src/configuration_editor/protocol_tests.rs index 068086a8..1e94b7bc 100644 --- a/crates/basilisk-lsp/src/configuration_editor/protocol_tests.rs +++ b/crates/basilisk-lsp/src/configuration_editor/protocol_tests.rs @@ -1,4 +1,5 @@ -//! Unit tests for configuration-editor protocol guards. +//! Unit tests for configuration-editor protocol guards and the atomic +//! Typeshed configuration transaction ([LSPCFGED-TYPESHED]). use basilisk_config::{BasiliskConfig, ConfigDocument}; @@ -58,47 +59,15 @@ fn snapshot_guards_reject_cross_mode_and_cross_commit_actions() { type TestResult = Result>; -fn latest_snapshot( - commit: basilisk_stubs::typeshed::gittree::Oid, -) -> TestResult { - use basilisk_stubs::typeshed::archive::ArchiveVfs; - use basilisk_stubs::typeshed::snapshot::Snapshot; - use basilisk_stubs::typeshed::source::{ - Provenance, SourceIdentity, SourceKind, StatusWarning, Transport, - }; - use basilisk_stubs::typeshed::warning::{TypeshedWarning, UnpinnedKind}; - - let bundled = basilisk_stubs::typeshed::bundle::bundled_snapshot()?; - let archive = bundled.vfs.archive().clone(); - let tree = archive.root_tree_oid()?; - let identity = SourceIdentity::Commit { - commit, - pinned: false, - }; - let mut status = bundled.status.clone(); - status.active_source = SourceKind::Latest; - status.commit = Some(commit); - status.tree = Some(tree); - status.transport = Transport::Codeload; - status.provenance = Provenance::GithubTlsAttested; - status.license_reference = Some(format!( - "https://github.com/python/typeshed/blob/{}/LICENSE", - commit.to_hex() - )); - status.warnings = - StatusWarning::list(&[TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled)]); - let identity_key = identity.uri_component(); - Ok(Snapshot::build( - identity, - status, - ArchiveVfs::new(identity_key, archive), - None, - )?) -} +type ClientMessages = tokio::sync::mpsc::UnboundedReceiver<(String, serde_json::Value)>; -async fn initialized_test_service() -> TestResult<( +/// Spin up an initialized in-process server whose client pump records every +/// client-bound message and answers `workspace/applyEdit` with `approve`. +async fn initialized_test_service( + approve_edits: bool, +) -> TestResult<( tower_lsp::LspService, - tokio::sync::mpsc::UnboundedReceiver, + ClientMessages, tokio::task::JoinHandle<()>, )> { use futures_util::{SinkExt as _, StreamExt as _}; @@ -106,17 +75,17 @@ async fn initialized_test_service() -> TestResult<( let (mut service, socket) = tower_lsp::LspService::new(crate::server::LspServer::new); let (mut requests, mut responses) = socket.split(); - let (apply_tx, apply_rx) = tokio::sync::mpsc::unbounded_channel(); + let (message_tx, message_rx) = tokio::sync::mpsc::unbounded_channel(); let pump = tokio::spawn(async move { while let Some(request) = requests.next().await { + let method = request.method().to_owned(); + let params = request.params().cloned().unwrap_or(serde_json::Value::Null); + let _ = message_tx.send((method.clone(), params)); let Some(id) = request.id().cloned() else { continue; }; - let result = if request.method() == "workspace/applyEdit" { - if let Some(params) = request.params().cloned() { - let _ = apply_tx.send(params); - } - serde_json::json!({ "applied": true }) + let result = if method == "workspace/applyEdit" { + serde_json::json!({ "applied": approve_edits }) } else { serde_json::Value::Null }; @@ -153,100 +122,297 @@ async fn initialized_test_service() -> TestResult<( if let Some(watcher) = service.inner().config_watcher.lock().await.take() { watcher.abort(); } - Ok((service, apply_rx, pump)) + Ok((service, message_rx, pump)) } -async fn pin_current_round_trip( - snapshot: basilisk_stubs::typeshed::snapshot::Snapshot, -) -> TestResult<()> { +/// Register one temporary workspace root with an active bundled generation. +async fn install_bundled_root( + server: &crate::server::LspServer, +) -> TestResult<(tempfile::TempDir, String, std::path::PathBuf)> { use std::sync::Arc; - use super::super::model::{ - ApplyConfigurationRequest, TypeshedAction, TypeshedActionRequest, TypeshedActionResult, - }; use crate::config::AnalysisMode; use crate::server::typeshed_status::TypeshedGeneration; use crate::workspace::WorkspaceIndex; - let expected = snapshot - .status - .commit - .ok_or("active snapshot has no commit")? - .to_hex(); let root = tempfile::tempdir()?; std::fs::write(root.path().join("pyproject.toml"), "[tool.basilisk]\n")?; let root_path = root.path().to_path_buf(); let root_uri = tower_lsp::lsp_types::Url::from_file_path(&root_path) .map_err(|()| "temporary root has no file URI")? .to_string(); - let (service, mut apply_rx, pump) = initialized_test_service().await?; - let server = service.inner(); *server.workspace_roots.write().await = vec![root_path.clone()]; *server.index.write().await = Some(WorkspaceIndex::new( vec![root_path.clone()], AnalysisMode::WholeModule, BasiliskConfig::default(), )); + let snapshot = basilisk_stubs::typeshed::bundle::bundled_snapshot()?; let _ = server.typeshed_generations.write().await.insert( root_path.clone(), TypeshedGeneration::Ready(Arc::new(snapshot)), ); + Ok((root, root_uri, root_path)) +} + +/// Drain recorded client messages until `method` is seen or the budget ends. +async fn drain_messages_until( + messages: &mut ClientMessages, + method: &str, +) -> Vec<(String, serde_json::Value)> { + let mut seen = Vec::new(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + match tokio::time::timeout(remaining, messages.recv()).await { + Ok(Some(message)) => { + let matched = message.0 == method; + seen.push(message); + if matched { + return seen; + } + } + Ok(None) | Err(_) => return seen, + } + } +} + +fn status_lifecycles(messages: &[(String, serde_json::Value)]) -> Vec { + messages + .iter() + .filter(|(method, _)| { + method == basilisk_common::configuration_editor::TYPESHED_STATUS_CHANGED + }) + .filter_map(|(_, params)| params.pointer("/status/lifecycle/kind")) + .filter_map(serde_json::Value::as_str) + .map(str::to_owned) + .collect() +} + +async fn preview_and_apply( + server: &crate::server::LspServer, + root_uri: &str, + mutations: Vec, +) -> tower_lsp::jsonrpc::Result { + use super::super::model::{ApplyConfigurationRequest, PreviewConfigurationRequest}; let current = server .configuration_snapshot(ConfigurationSnapshotRequest { - root_uri: root_uri.clone(), + root_uri: root_uri.to_owned(), }) .await?; - let action = server - .typeshed_action(TypeshedActionRequest { - root_uri: root_uri.clone(), + let preview = server + .preview_configuration_change(PreviewConfigurationRequest { + root_uri: root_uri.to_owned(), base_revision: current.revision, - action: TypeshedAction::PinCurrent, + mutations, }) .await?; - let TypeshedActionResult::Preview { preview } = action else { - return Err("Pin current did not return a preview".into()); - }; - let _applied = server + server .apply_configuration_change(ApplyConfigurationRequest { - root_uri, + root_uri: root_uri.to_owned(), preview_id: preview.preview_id, }) - .await?; + .await +} + +/// [LSPCFGED-TYPESHED] — the UI-glitch proof: setting the pin lands the edit +/// and publishes ONLY the terminal Ready status. No acquiring, blocked, or +/// downloading state ever reaches the wire on a configuration change. +#[tokio::test] +async fn pin_edit_applies_atomically_without_intermediate_states() -> TestResult<()> { + use super::super::model::{EditorMutation, TypeshedLifecycle, TypeshedSettingKey}; + + let expected = basilisk_stubs::typeshed::bundle::bundled_commit_sha(); + let (service, mut messages, pump) = initialized_test_service(true).await?; + let server = service.inner(); + let (_root, root_uri, root_path) = install_bundled_root(server).await?; + + let applied = preview_and_apply( + server, + &root_uri, + vec![EditorMutation::SetTypeshedSetting { + key: TypeshedSettingKey::TypeshedCommit, + value: expected.to_owned(), + }], + ) + .await?; + assert_eq!(applied.typeshed.status.lifecycle, TypeshedLifecycle::Ready); - let apply_edit = tokio::time::timeout(std::time::Duration::from_secs(2), apply_rx.recv()) - .await? - .ok_or("Pin current sent no workspace edit")?; - let edited_text = apply_edit - .pointer("/edit/documentChanges/0/edits/0/newText") + let seen = drain_messages_until( + &mut messages, + basilisk_common::configuration_editor::TYPESHED_STATUS_CHANGED, + ) + .await; + let edit_text = seen + .iter() + .find(|(method, _)| method == "workspace/applyEdit") + .and_then(|(_, params)| params.pointer("/edit/documentChanges/0/edits/0/newText")) .and_then(serde_json::Value::as_str) - .ok_or("Pin current workspace edit carried no replacement text")?; + .ok_or("pin apply sent no workspace edit")?; assert!( - edited_text.contains(&format!("typeshed-commit = \"{expected}\"")), - "workspace edit must write the active commit: {edited_text}" + edit_text.contains(&format!("typeshed-commit = \"{expected}\"")), + "workspace edit must write the pin: {edit_text}" + ); + let lifecycles = status_lifecycles(&seen); + assert_eq!( + lifecycles, + vec!["Ready".to_owned()], + "one terminal publish, nothing intermediate" ); let document = server.configuration_editor.effective_document(&root_path)?; + assert_eq!(document.config.typeshed_commit.as_deref(), Some(expected)); + pump.abort(); + Ok(()) +} + +/// [STUBRES-TYPESHED-PIN]: a valid pin that is not on this machine is VALID +/// configuration — the edit lands, nothing downloads, and the root publishes +/// the terminal NO SOURCE state naming the recovery commands. +#[tokio::test] +async fn missing_pin_tanks_to_terminal_no_source_without_downloading() -> TestResult<()> { + use super::super::model::{EditorMutation, TypeshedLifecycle, TypeshedSettingKey}; + + let missing = "0123456789012345678901234567890123456789"; + let empty_store = tempfile::tempdir()?; + let (service, mut messages, pump) = initialized_test_service(true).await?; + let server = service.inner(); + let (_root, root_uri, root_path) = install_bundled_root(server).await?; + + let applied = preview_and_apply( + server, + &root_uri, + vec![ + EditorMutation::SetTypeshedSetting { + key: TypeshedSettingKey::TypeshedCommit, + value: missing.to_owned(), + }, + EditorMutation::SetTypeshedSetting { + key: TypeshedSettingKey::TypeshedStorePath, + value: empty_store.path().to_string_lossy().into_owned(), + }, + ], + ) + .await?; assert_eq!( - document.config.typeshed_commit.as_deref(), - Some(expected.as_str()) + applied.typeshed.status.lifecycle, + TypeshedLifecycle::NoSource ); - assert!(document - .content - .contains(&format!("typeshed-commit = \"{expected}\""))); + + let seen = drain_messages_until( + &mut messages, + basilisk_common::configuration_editor::TYPESHED_STATUS_CHANGED, + ) + .await; + let lifecycles = status_lifecycles(&seen); + assert_eq!( + lifecycles, + vec!["NoSource".to_owned()], + "a missing pin publishes exactly one terminal state and never Downloading" + ); + let reason = seen + .iter() + .rev() + .find(|(method, _)| { + method == basilisk_common::configuration_editor::TYPESHED_STATUS_CHANGED + }) + .and_then(|(_, params)| params.pointer("/status/noSourceReason")) + .and_then(serde_json::Value::as_str) + .ok_or("NoSource status carried no reason")?; + assert!(reason.contains("NO SOURCE"), "loud reason: {reason}"); + assert!(reason.contains(missing), "reason names the pin: {reason}"); + assert!( + reason.contains("basilisk typeshed download"), + "reason names the recovery command: {reason}" + ); + // The store stayed byte-empty: nothing attempted a download. + assert_eq!( + std::fs::read_dir(empty_store.path())?.count(), + 0, + "resolution must never write or fetch" + ); + let document = server.configuration_editor.effective_document(&root_path)?; + assert_eq!(document.config.typeshed_commit.as_deref(), Some(missing)); pump.abort(); Ok(()) } +/// A rejected client edit publishes NOTHING: the staged generation is +/// dropped, the previous source keeps serving, and no status flickers. #[tokio::test] -async fn pin_current_applies_the_latest_active_commit() -> TestResult<()> { - let commit = basilisk_stubs::typeshed::gittree::Oid::from_hex( - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - )?; - pin_current_round_trip(latest_snapshot(commit)?).await +async fn rejected_edit_publishes_no_status_change() -> TestResult<()> { + use super::super::model::{EditorMutation, TypeshedSettingKey}; + use crate::server::typeshed_status::TypeshedGeneration; + + let (service, mut messages, pump) = initialized_test_service(false).await?; + let server = service.inner(); + let (_root, root_uri, root_path) = install_bundled_root(server).await?; + + let result = preview_and_apply( + server, + &root_uri, + vec![EditorMutation::SetTypeshedSetting { + key: TypeshedSettingKey::TypeshedCommit, + value: "0123456789012345678901234567890123456789".to_owned(), + }], + ) + .await; + assert!(result.is_err(), "a rejected edit must fail the apply"); + + let seen = drain_messages_until(&mut messages, "workspace/applyEdit").await; + assert!( + status_lifecycles(&seen).is_empty(), + "no status may be published for an unapplied edit" + ); + let generation = server + .typeshed_generations + .read() + .await + .get(&root_path) + .cloned(); + assert!( + generation + .as_ref() + .and_then(TypeshedGeneration::ready_snapshot) + .is_some(), + "the previous Ready generation must survive a rejected edit" + ); + pump.abort(); + Ok(()) } +/// [LSPCFGED-TYPESHED]: `ViewLicense` returns the active immutable license +/// document for the effective bundled pin. #[tokio::test] -async fn pin_current_offline_applies_the_bundled_commit() -> TestResult<()> { - let snapshot = basilisk_stubs::typeshed::bundle::bundled_snapshot()?; - pin_current_round_trip(snapshot).await +async fn view_license_returns_the_active_immutable_license() -> TestResult<()> { + use super::super::model::{TypeshedAction, TypeshedActionRequest, TypeshedActionResult}; + + let (service, mut messages, pump) = initialized_test_service(true).await?; + let server = service.inner(); + let (_root, root_uri, _root_path) = install_bundled_root(server).await?; + + let current = server + .configuration_snapshot(ConfigurationSnapshotRequest { + root_uri: root_uri.clone(), + }) + .await?; + let action = server + .typeshed_action(TypeshedActionRequest { + root_uri, + base_revision: current.revision, + action: TypeshedAction::ViewLicense, + }) + .await?; + let TypeshedActionResult::License { license } = action else { + return Err("ViewLicense did not return a license document".into()); + }; + assert_eq!(license.title, "Typeshed License"); + assert!(license.read_only); + assert!( + !license.content.trim().is_empty(), + "bundled license text must be present" + ); + messages.close(); + pump.abort(); + Ok(()) } diff --git a/crates/basilisk-lsp/src/configuration_editor/snapshot_tests.rs b/crates/basilisk-lsp/src/configuration_editor/snapshot_tests.rs index d78273fc..082b620d 100644 --- a/crates/basilisk-lsp/src/configuration_editor/snapshot_tests.rs +++ b/crates/basilisk-lsp/src/configuration_editor/snapshot_tests.rs @@ -7,9 +7,7 @@ use std::sync::Arc; use basilisk_config::{BasiliskConfig, RuleSeverity as ConfigSeverity}; use basilisk_stubs::typeshed::gittree::Oid; -use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceKind, Transport, TypeshedStatus, -}; +use basilisk_stubs::typeshed::source::{LicenseStatus, SourceKind, TypeshedStatus}; use basilisk_stubs::typeshed::warning::WarningSeverity; use super::{build_snapshot, hypothetical_inventory, inventory, occurrences, page_occurrences}; @@ -170,9 +168,15 @@ fn snapshot_reports_rule_entries_effective_severities_and_tag_entries() { let _ = std::fs::remove_dir_all(root); } -/// The settled, unpinned download the fixture root resolves to. -fn assert_downloaded_latest(snapshot: &crate::configuration_editor::model::ConfigurationSnapshot) { - assert_eq!(snapshot.typeshed.source, TypeshedSource::Latest); +/// The bundled default the fixture root resolves to: an unset pin IS the +/// bundled commit ([STUBRES-TYPESHED]), still explicitly `UNPINNED`. +fn assert_bundled_default(snapshot: &crate::configuration_editor::model::ConfigurationSnapshot) { + assert_eq!( + snapshot.typeshed.source, + TypeshedSource::ExactCommit { + commit: "83c2518a9e6abbda0c44592c3483de459198f887".to_owned(), + } + ); assert_eq!(snapshot.typeshed.status.lifecycle, TypeshedLifecycle::Ready); assert_eq!( snapshot.typeshed.status.commit_identity.as_deref(), @@ -187,29 +191,16 @@ fn assert_downloaded_latest(snapshot: &crate::configuration_editor::model::Confi .map(|warning| warning.code.as_str()), Some("UNPINNED") ); - // A downloaded source states its complete download policy — explicit - // entries and defaults resolved, no per-control widget descriptions. - let downloads = snapshot.typeshed.downloads.clone(); - assert_eq!( - downloads.as_ref().map(|policy| policy.reuse_downloads), - Some(false) - ); - assert_eq!( - downloads.as_ref().map(|policy| policy.verify_content), - Some(false) - ); - // Pinning is offered because a gate-accepted commit is active, and the - // offer carries the exact SHA it would write. - assert_eq!( - snapshot.typeshed.pinnable_commit.as_deref(), - Some("83c2518a9e6abbda0c44592c3483de459198f887") - ); assert!(snapshot.typeshed.license_available); + assert!( + snapshot.typeshed.store_folder.is_some(), + "a pinned source states the store folder it resolves from" + ); } -/// [LSPCFGED-TYPESHED]: the snapshot's Typeshed source, download policy, and -/// status come entirely from the server's parsed config plus one shared -/// terminal runtime status. +/// [LSPCFGED-TYPESHED]: the snapshot's Typeshed source and status come +/// entirely from the server's parsed config plus one shared terminal runtime +/// status — there are exactly two sources and no download-policy knobs. #[test] fn snapshot_describes_typeshed_controls_and_terminal_status() { let Some((root, index)) = indexed_root("typeshed") else { @@ -218,8 +209,6 @@ fn snapshot_describes_typeshed_controls_and_terminal_status() { let Ok(mut document) = basilisk_config::discover_config_document(&root) else { unreachable!("fixture configuration must parse"); }; - document.config.typeshed_cache = Some(false); - document.config.typeshed_verify = Some(false); let Ok(commit) = Oid::from_hex("83c2518a9e6abbda0c44592c3483de459198f887") else { unreachable!("fixture SHA must parse"); }; @@ -227,11 +216,8 @@ fn snapshot_describes_typeshed_controls_and_terminal_status() { active_source: SourceKind::Bundled, commit: Some(commit), tree: None, - transport: Transport::EmbeddedZip, license_status: LicenseStatus::Approved, license_reference: Some("typeshed://license/83c2518".to_owned()), - provenance: Provenance::BundleVetted, - signed_release: false, warnings: vec![basilisk_stubs::typeshed::source::StatusWarning { code: "UNPINNED".to_owned(), message: "UNPINNED — choose the pinned-commit source to make this reproducible" @@ -245,9 +231,9 @@ fn snapshot_describes_typeshed_controls_and_terminal_status() { runtime_snapshot.status = status; let generation = TypeshedGeneration::Ready(Arc::new(runtime_snapshot)); let snapshot = build_snapshot(&index, &root, &document, Some(&generation)); - assert_downloaded_latest(&snapshot); - // A pinned commit is carried BY the active source, and an already-pinned - // source offers no second pin. + assert_bundled_default(&snapshot); + // A pinned commit is carried BY the active source; the matching active + // generation keeps its license reachable. document.config.typeshed_commit = Some("83c2518a9e6abbda0c44592c3483de459198f887".to_owned()); let pinned = build_snapshot(&index, &root, &document, Some(&generation)); assert_eq!( @@ -256,14 +242,11 @@ fn snapshot_describes_typeshed_controls_and_terminal_status() { commit: "83c2518a9e6abbda0c44592c3483de459198f887".to_owned(), } ); - assert_eq!(pinned.typeshed.pinnable_commit, None); - assert!( - pinned.typeshed.downloads.is_some(), - "a pinned commit is still downloaded, so it keeps its download policy" - ); + assert!(pinned.typeshed.license_available); document.config.typeshed_commit = None; - // A user-managed folder downloads nothing and has no upstream commit. + // A user-managed folder resolves nothing from the store and states its + // own terms. document.config.typeshed_path = Some(root.join("custom-typeshed")); let custom = build_snapshot(&index, &root, &document, Some(&generation)); assert_eq!( @@ -272,63 +255,57 @@ fn snapshot_describes_typeshed_controls_and_terminal_status() { path: root.join("custom-typeshed").to_string_lossy().into_owned(), } ); - assert_eq!(custom.typeshed.downloads, None); - assert_eq!(custom.typeshed.pinnable_commit, None); + assert_eq!(custom.typeshed.store_folder, None); assert!( custom.typeshed.license_available, "a custom tree still reports its user-managed terms" ); document.config.typeshed_path = None; - let blocked = TypeshedGeneration::Blocked { - failure: TypeshedFailure::acquisition("exact commit unavailable"), + let no_source = TypeshedGeneration::NoSource { + failure: TypeshedFailure::resolution("NO SOURCE — the pin is not on this machine"), }; - let snapshot = build_snapshot(&index, &root, &document, Some(&blocked)); + let snapshot = build_snapshot(&index, &root, &document, Some(&no_source)); assert_eq!( snapshot.typeshed.status.lifecycle, - TypeshedLifecycle::Blocked + TypeshedLifecycle::NoSource ); assert_eq!( snapshot.typeshed.status.license_status, TypeshedLicenseStatus::Unavailable ); assert_eq!( - snapshot.typeshed.status.blocked_reason.as_deref(), - Some("exact commit unavailable") + snapshot.typeshed.status.no_source_reason.as_deref(), + Some("NO SOURCE — the pin is not on this machine") ); let _ = std::fs::remove_dir_all(root); } -/// [LSPCFGED-TYPESHED]: acquisition is an atomic source transition. While a -/// candidate is being acquired, no source-policy control may start a second -/// mutation against the in-flight generation. +/// [LSPCFGED-TYPESHED]: a root whose resolution has not run projects the +/// terminal `NoSource` state — the wire model holds no acquiring/blocked +/// lifecycle a client could render as a panel overlay. #[test] -fn acquiring_typeshed_offers_no_source_transition() { - let Some((root, index)) = indexed_root("typeshed-acquiring") else { +fn missing_generation_projects_terminal_no_source() { + let Some((root, index)) = indexed_root("typeshed-unresolved") else { unreachable!("indexed fixture must produce diagnostics"); }; let Ok(document) = basilisk_config::discover_config_document(&root) else { unreachable!("fixture configuration must parse"); }; - let snapshot = build_snapshot( - &index, - &root, - &document, - Some(&TypeshedGeneration::Acquiring), - ); + let snapshot = build_snapshot(&index, &root, &document, None); assert_eq!( snapshot.typeshed.status.lifecycle, - TypeshedLifecycle::Acquiring + TypeshedLifecycle::NoSource ); assert_eq!( - snapshot.typeshed.pinnable_commit, None, - "an in-flight generation has no settled commit to pin" + snapshot.typeshed.status.no_source_reason.as_deref(), + Some("typeshed resolution has not run for this root") ); assert!( !snapshot.typeshed.license_available, - "no license document exists until the candidate settles" + "no license document exists without an active generation" ); let _ = std::fs::remove_dir_all(root); diff --git a/crates/basilisk-lsp/src/configuration_editor/snapshot_typeshed.rs b/crates/basilisk-lsp/src/configuration_editor/snapshot_typeshed.rs index b36c9725..24e8beb8 100644 --- a/crates/basilisk-lsp/src/configuration_editor/snapshot_typeshed.rs +++ b/crates/basilisk-lsp/src/configuration_editor/snapshot_typeshed.rs @@ -1,28 +1,26 @@ //! Server-described Typeshed controls and typed lifecycle projection. use basilisk_config::BasiliskConfig; -use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceKind, Transport, TypeshedStatus, -}; +use basilisk_stubs::typeshed::bundle::bundled_commit_sha; +use basilisk_stubs::typeshed::source::{LicenseStatus, SourceKind, TypeshedStatus}; use basilisk_stubs::typeshed::warning::WarningSeverity; use super::model::{ - TypeshedActiveSource, TypeshedConfigurationState, TypeshedDownloadPolicy, - TypeshedLicenseStatus, TypeshedLifecycle, TypeshedProvenance, TypeshedSource, - TypeshedStatusState, TypeshedTransport, TypeshedWarningSeverity, TypeshedWarningState, + TypeshedActiveSource, TypeshedConfigurationState, TypeshedLicenseStatus, TypeshedLifecycle, + TypeshedSource, TypeshedStatusState, TypeshedWarningSeverity, TypeshedWarningState, }; use crate::server::typeshed_status::TypeshedGeneration; -/// Project the one active source, carrying the value that defines it. +/// Project the one active source, carrying the value that defines it. An +/// unset pin IS the bundled commit ([STUBRES-TYPESHED]): the picker shows the +/// effective SHA and the `UNPINNED` warning says it is not yet reproducible. fn source(config: &BasiliskConfig) -> TypeshedSource { config.typeshed_path.as_ref().map_or_else( - || { - config + || TypeshedSource::ExactCommit { + commit: config .typeshed_commit .clone() - .map_or(TypeshedSource::Latest, |commit| { - TypeshedSource::ExactCommit { commit } - }) + .unwrap_or_else(|| bundled_commit_sha().to_owned()), }, |path| TypeshedSource::CustomFolder { path: path.to_string_lossy().into_owned(), @@ -30,63 +28,28 @@ fn source(config: &BasiliskConfig) -> TypeshedSource { ) } -/// The download policy of a downloaded source. A user-managed folder -/// downloads nothing, so it has none ([LSPCFGED-TYPESHED]). -fn downloads(config: &BasiliskConfig, source: &TypeshedSource) -> Option { +/// The store directory pins resolve from. A custom folder resolves nothing +/// from the store, so it has none ([STUBRES-TYPESHED-STORE]). +fn store_folder(config: &BasiliskConfig, source: &TypeshedSource) -> Option { if matches!(source, TypeshedSource::CustomFolder { .. }) { return None; } - Some(TypeshedDownloadPolicy { - reuse_downloads: config.typeshed_cache.unwrap_or(true), - verify_content: config.typeshed_verify.unwrap_or(true), - archive_url: config.typeshed_url.clone(), - cache_folder: config - .typeshed_cache_path - .as_ref() - .map(|path| path.to_string_lossy().into_owned()), - }) + config + .typeshed_store_path + .clone() + .or_else(basilisk_stubs::typeshed::runtime::default_store_path) + .map(|path| path.to_string_lossy().into_owned()) } -/// The commit `PinCurrent` would write, present only when pinning is possible: -/// the source must be an unpinned download and a gate-accepted commit must be -/// active. A source the user can select but never reach does not exist. -fn pinnable_commit( - source: &TypeshedSource, - acquiring: bool, - status: Option<&TypeshedStatus>, -) -> Option { - if acquiring || !matches!(source, TypeshedSource::Latest) { - return None; +fn license_available(source: &TypeshedSource, generation: Option<&TypeshedGeneration>) -> bool { + match source { + // A custom folder always answers `ViewLicense` — with "not supplied". + TypeshedSource::CustomFolder { .. } => true, + TypeshedSource::ExactCommit { commit } => generation + .and_then(TypeshedGeneration::ready_status) + .and_then(|status| status.commit) + .is_some_and(|active| active.to_hex() == *commit), } - status - .and_then(|current| current.commit) - .map(|commit| commit.to_hex()) -} - -fn license_available( - config: &BasiliskConfig, - source: &TypeshedSource, - lifecycle: TypeshedLifecycle, - status: Option<&TypeshedStatus>, -) -> bool { - if matches!(source, TypeshedSource::CustomFolder { .. }) { - return lifecycle != TypeshedLifecycle::Acquiring; - } - lifecycle == TypeshedLifecycle::Ready - && status.is_some_and(|current| match source { - TypeshedSource::ExactCommit { .. } => { - config.typeshed_commit.as_deref().is_some_and(|commit| { - current - .commit - .is_some_and(|active| active.to_hex() == commit) - }) - } - TypeshedSource::Latest => matches!( - current.active_source, - SourceKind::Latest | SourceKind::Bundled - ), - TypeshedSource::CustomFolder { .. } => false, - }) } pub(super) fn typeshed_configuration( @@ -94,66 +57,43 @@ pub(super) fn typeshed_configuration( generation: Option<&TypeshedGeneration>, ) -> TypeshedConfigurationState { let source = source(config); - let ready_status = generation.and_then(TypeshedGeneration::ready_status); - let status = - generation.map_or_else(|| status_projection(None), TypeshedGeneration::status_state); - let acquiring = status.lifecycle == TypeshedLifecycle::Acquiring; + let status = generation.map_or_else( + || TypeshedStatusState { + lifecycle: TypeshedLifecycle::NoSource, + no_source_reason: Some("typeshed resolution has not run for this root".to_owned()), + active_source: None, + commit_identity: None, + license_status: TypeshedLicenseStatus::Unavailable, + warnings: Vec::new(), + }, + TypeshedGeneration::status_state, + ); TypeshedConfigurationState { - downloads: downloads(config, &source), - pinnable_commit: pinnable_commit(&source, acquiring, ready_status), - license_available: license_available(config, &source, status.lifecycle, ready_status), + store_folder: store_folder(config, &source), + license_available: license_available(&source, generation), source, status, } } -pub(crate) fn status_projection(status: Option<&TypeshedStatus>) -> TypeshedStatusState { - let Some(status) = status else { - return TypeshedStatusState { - lifecycle: TypeshedLifecycle::Acquiring, - blocked_reason: None, - active_source: None, - commit_identity: None, - transport: None, - license_status: TypeshedLicenseStatus::Acquiring, - provenance: TypeshedProvenance::Pending, - signed_release: false, - warnings: Vec::new(), - }; - }; +/// Project an active snapshot's status. A snapshot only exists after every +/// activation gate passed, so its lifecycle is always `Ready` — a blocked or +/// missing source is a [`TypeshedGeneration::NoSource`], never a snapshot. +pub(crate) fn ready_projection(status: &TypeshedStatus) -> TypeshedStatusState { TypeshedStatusState { - lifecycle: if status.license_status == LicenseStatus::Changed { - TypeshedLifecycle::Blocked - } else { - TypeshedLifecycle::Ready - }, - blocked_reason: (status.license_status == LicenseStatus::Changed) - .then(|| "Typeshed license identity changed; activation requires review".to_owned()), + lifecycle: TypeshedLifecycle::Ready, + no_source_reason: None, active_source: Some(match status.active_source { SourceKind::Custom => TypeshedActiveSource::Custom, SourceKind::ExactCommit => TypeshedActiveSource::ExactCommit, - SourceKind::Latest => TypeshedActiveSource::Latest, SourceKind::Bundled => TypeshedActiveSource::Bundled, }), commit_identity: status.commit.map(|oid| oid.to_hex()), - transport: Some(match status.transport { - Transport::CustomPath => TypeshedTransport::CustomPath, - Transport::EmbeddedZip => TypeshedTransport::EmbeddedZip, - Transport::Codeload => TypeshedTransport::Codeload, - Transport::Mirror => TypeshedTransport::Mirror, - }), license_status: match status.license_status { LicenseStatus::Approved => TypeshedLicenseStatus::Approved, LicenseStatus::Changed => TypeshedLicenseStatus::Changed, LicenseStatus::NotSupplied => TypeshedLicenseStatus::NotSupplied, }, - provenance: match status.provenance { - Provenance::GithubTlsAttested => TypeshedProvenance::GithubTlsAttested, - Provenance::Unverified => TypeshedProvenance::Unverified, - Provenance::BundleVetted => TypeshedProvenance::BundleVetted, - Provenance::UserManaged => TypeshedProvenance::UserManaged, - }, - signed_release: status.signed_release, warnings: status .warnings .iter() diff --git a/crates/basilisk-lsp/src/configuration_editor/snapshot_typeshed_tests.rs b/crates/basilisk-lsp/src/configuration_editor/snapshot_typeshed_tests.rs index 6b37bd10..15674b3c 100644 --- a/crates/basilisk-lsp/src/configuration_editor/snapshot_typeshed_tests.rs +++ b/crates/basilisk-lsp/src/configuration_editor/snapshot_typeshed_tests.rs @@ -1,79 +1,117 @@ //! Tests for the server-described Typeshed source projection. use std::path::PathBuf; +use std::sync::Arc; + +use basilisk_stubs::typeshed::bundle::{bundled_commit_sha, bundled_snapshot}; use super::*; -/// [LSPCFGED-TYPESHED]: a blocked official source has no immutable license -/// document to show, while a user-managed tree always states its own terms. +/// [STUBRES-TYPESHED]: an unset pin IS the bundled commit — the picker shows +/// the effective SHA, never a third "latest" source. #[test] -fn blocked_official_source_cannot_offer_a_stale_license() { - let exact = BasiliskConfig { +fn unset_pin_reports_the_bundled_commit_as_the_effective_source() { + let unset = BasiliskConfig::default(); + assert_eq!( + source(&unset), + TypeshedSource::ExactCommit { + commit: bundled_commit_sha().to_owned(), + } + ); + + let pinned = BasiliskConfig { typeshed_commit: Some("a".repeat(40)), ..BasiliskConfig::default() }; - let exact_source = source(&exact); assert_eq!( - exact_source, + source(&pinned), TypeshedSource::ExactCommit { commit: "a".repeat(40), } ); - assert!(!license_available( - &exact, - &exact_source, - TypeshedLifecycle::Blocked, - None, - )); let custom = BasiliskConfig { typeshed_path: Some(PathBuf::from("/user/typeshed")), ..BasiliskConfig::default() }; - let custom_source = source(&custom); - assert!(matches!(custom_source, TypeshedSource::CustomFolder { .. })); - assert!(license_available( - &custom, - &custom_source, - TypeshedLifecycle::Blocked, - None, + assert!(matches!( + source(&custom), + TypeshedSource::CustomFolder { .. } )); - assert_eq!( - downloads(&custom, &custom_source), - None, - "a user-managed folder downloads nothing" - ); } -/// A source that cannot be reached is never offered: pinning requires an -/// unpinned download with a settled commit behind it. +/// [STUBRES-TYPESHED-STORE]: pins resolve from a store folder (configured or +/// canonical); a custom folder resolves nothing from the store. #[test] -fn pinning_is_offered_only_for_an_unpinned_settled_download() { - let latest = BasiliskConfig::default(); - let latest_source = source(&latest); - assert_eq!(latest_source, TypeshedSource::Latest); - assert_eq!(pinnable_commit(&latest_source, false, None), None); +fn store_folder_exists_only_for_pinned_sources() { + let configured = BasiliskConfig { + typeshed_store_path: Some(PathBuf::from("/stores/typeshed")), + ..BasiliskConfig::default() + }; assert_eq!( - pinnable_commit(&latest_source, true, None), - None, - "an in-flight acquisition offers no pin" + store_folder(&configured, &source(&configured)).as_deref(), + Some("/stores/typeshed") ); - let pinned = BasiliskConfig { - typeshed_commit: Some("b".repeat(40)), + let custom = BasiliskConfig { + typeshed_path: Some(PathBuf::from("/user/typeshed")), + typeshed_store_path: Some(PathBuf::from("/stores/typeshed")), ..BasiliskConfig::default() }; - assert_eq!(pinnable_commit(&source(&pinned), false, None), None); + assert_eq!(store_folder(&custom, &source(&custom)), None); +} + +/// [LSPCFGED-TYPESHED]: a pin without a matching active generation has no +/// immutable license document to show, while a user-managed tree always +/// answers `ViewLicense` with its own terms. +#[test] +fn license_availability_tracks_the_matching_active_generation() { + let custom_source = TypeshedSource::CustomFolder { + path: "/user/typeshed".to_owned(), + }; + assert!(license_available(&custom_source, None)); + + let bundled_pin = TypeshedSource::ExactCommit { + commit: bundled_commit_sha().to_owned(), + }; + assert!(!license_available(&bundled_pin, None)); + + let Ok(snapshot) = bundled_snapshot() else { + unreachable!("release bundle must activate"); + }; + let ready = TypeshedGeneration::Ready(Arc::new(snapshot)); + assert!(license_available(&bundled_pin, Some(&ready))); + + let other_pin = TypeshedSource::ExactCommit { + commit: "a".repeat(40), + }; + assert!( + !license_available(&other_pin, Some(&ready)), + "a pin must not surface a different commit's license" + ); +} + +/// [LSPCFGED-TYPESHED]: every projection is terminal. A snapshot is always +/// `Ready`; an unresolved root is `NoSource` with its reason — there is no +/// intermediate state for a client to render as a blocking overlay. +#[test] +fn projections_are_always_terminal() { + let Ok(snapshot) = bundled_snapshot() else { + unreachable!("release bundle must activate"); + }; + let ready = ready_projection(&snapshot.status); + assert_eq!(ready.lifecycle, TypeshedLifecycle::Ready); + assert!(ready.no_source_reason.is_none()); + assert_eq!(ready.active_source, Some(TypeshedActiveSource::Bundled)); - // Defaults resolve on the wire so the client never re-derives them. - let policy = downloads(&latest, &latest_source); + let unresolved = typeshed_configuration(&BasiliskConfig::default(), None); + assert_eq!(unresolved.status.lifecycle, TypeshedLifecycle::NoSource); assert_eq!( - policy.as_ref().map(|policy| policy.reuse_downloads), - Some(true) + unresolved.status.no_source_reason.as_deref(), + Some("typeshed resolution has not run for this root") ); assert_eq!( - policy.as_ref().map(|policy| policy.verify_content), - Some(true) + unresolved.status.license_status, + TypeshedLicenseStatus::Unavailable ); - assert_eq!(policy.and_then(|policy| policy.archive_url), None); } diff --git a/crates/basilisk-lsp/src/configuration_editor/transaction.rs b/crates/basilisk-lsp/src/configuration_editor/transaction.rs index a5f6ed88..67c79080 100644 --- a/crates/basilisk-lsp/src/configuration_editor/transaction.rs +++ b/crates/basilisk-lsp/src/configuration_editor/transaction.rs @@ -3,7 +3,9 @@ use std::path::Path; use std::sync::Arc; -use basilisk_config::{build_rule_patch, ConfigDocument, ConfigPatch, RuleConfigUpdate}; +use basilisk_config::{ + build_configuration_patch, ConfigDocument, ConfigPatch, ConfigurationUpdate, RuleConfigUpdate, +}; use tower_lsp::jsonrpc::Result as LspResult; use tower_lsp::lsp_types::{ CreateFile, CreateFileOptions, DocumentChangeOperation, DocumentChanges, OneOf, @@ -33,7 +35,7 @@ pub(crate) struct ConfigurationRefreshHandles { pub(crate) workspace_roots: Arc>>, /// Explicit interpreter supplied by the editor initialization options. pub(crate) python_interpreter: Arc>>, - /// Root-keyed active/acquiring/blocked Typeshed generations. + /// Root-keyed terminal (Ready/NoSource) Typeshed generations. pub(crate) typeshed_generations: Arc>, /// Whether every workspace root has completed its first ready scan. @@ -107,13 +109,27 @@ fn replacement_edit( }) } -/// Apply one validated entry update through the same client-edit service -/// used by the typed preview/apply protocol. +/// Apply one validated rule-entry update through the shared transaction. pub(crate) async fn apply_rule_updates( server: &LspServer, root: &Path, update: &RuleConfigUpdate, reason: &str, +) -> LspResult { + let update = ConfigurationUpdate { + rules: update.clone(), + typeshed: basilisk_config::TypeshedConfigUpdate::default(), + }; + apply_configuration_update(server, root, &update, reason).await +} + +/// Apply one validated configuration update through the same client-edit +/// service used by the typed preview/apply protocol. +pub(crate) async fn apply_configuration_update( + server: &LspServer, + root: &Path, + update: &ConfigurationUpdate, + reason: &str, ) -> LspResult { let effective = server .configuration_editor @@ -123,7 +139,7 @@ pub(crate) async fn apply_rule_updates( let disk_revision = server .configuration_editor .disk_revision_for(root, &document.revision); - let patch = build_rule_patch(&document, update).map_err(config_error)?; + let patch = build_configuration_patch(&document, update).map_err(config_error)?; apply_prepared_patch( server, root, @@ -136,6 +152,10 @@ pub(crate) async fn apply_rule_updates( .await } +/// The transaction order is the whole UI fix ([LSPCFGED-TYPESHED]): compute +/// the next terminal generation locally, land the edit, then publish — a +/// rejected edit drops the staged value with nothing ever published, so +/// clients never observe an intermediate state. pub(super) async fn apply_prepared_patch( server: &LspServer, root: &Path, @@ -146,30 +166,20 @@ pub(super) async fn apply_prepared_patch( reason: &str, ) -> LspResult { let edit = replacement_edit(document, patch, document_version)?; - let staged = super::typeshed_acquisition::stage_configuration_change( - server, + let staged = super::typeshed_resolution::stage_configuration_change( root, &document.config, &patch.config, ) - .await?; - let response = match server.client.apply_edit(edit).await { - Ok(response) => response, - Err(error) => { - if let Some(staged) = staged { - staged.rollback(server, root).await; - } - return Err(rpc_error_data( - "clientRejectedEdit", - "client failed to apply configuration edit", - serde_json::json!({ "error": error.to_string() }), - )); - } - }; + .await; + let response = server.client.apply_edit(edit).await.map_err(|error| { + rpc_error_data( + "clientRejectedEdit", + "client failed to apply configuration edit", + serde_json::json!({ "error": error.to_string() }), + ) + })?; if !response.applied { - if let Some(staged) = staged { - staged.rollback(server, root).await; - } return Err(rpc_error_data( "clientRejectedEdit", "client rejected configuration edit", @@ -191,33 +201,15 @@ pub(super) async fn apply_prepared_patch( ); } let handles = server.refresh_handles(); - let refresh = refresh_with_document_and_typeshed( - &handles, - root, - reason, - &applied, - staged - .as_ref() - .map(super::typeshed_acquisition::StagedGeneration::candidate), - ) - .await; - if let Err(error) = refresh { - if let Some(staged) = staged { - let cleanup = - refresh_with_document(&handles, root, "typeshedConfigurationBlocked", &applied) - .await; - staged - .block(server, root, "configuration refresh failed") - .await; - if let Err(cleanup_error) = cleanup { - tracing::warn!(root = %root.display(), error = %cleanup_error, "failed to clear analysis after Typeshed configuration failure"); - } - } - return Err(error); - } + let candidate = staged + .as_ref() + .and_then(super::typeshed_resolution::StagedResolution::candidate) + .cloned(); if let Some(staged) = staged { - staged.activate(server, root).await; + staged.publish(&handles, root).await; } + refresh_with_document_and_typeshed(&handles, root, reason, &applied, candidate.as_ref()) + .await?; Ok(applied) } @@ -263,10 +255,7 @@ pub(super) async fn refresh_with_document_and_typeshed( let generation_unavailable = candidate.is_none() && matches!( handles.typeshed_generations.read().await.get(root), - Some( - crate::server::typeshed_status::TypeshedGeneration::Acquiring - | crate::server::typeshed_status::TypeshedGeneration::Blocked { .. } - ) + Some(crate::server::typeshed_status::TypeshedGeneration::NoSource { .. }) ); if generation_unavailable { return commit_without_analysis(handles, root, reason, document).await; diff --git a/crates/basilisk-lsp/src/configuration_editor/typeshed_acquisition.rs b/crates/basilisk-lsp/src/configuration_editor/typeshed_acquisition.rs deleted file mode 100644 index 57bcca71..00000000 --- a/crates/basilisk-lsp/src/configuration_editor/typeshed_acquisition.rs +++ /dev/null @@ -1,406 +0,0 @@ -//! Root-keyed production acquisition with candidate staging and rollback. - -use std::path::Path; -use std::sync::Arc; - -use basilisk_config::BasiliskConfig; -use basilisk_stubs::typeshed::snapshot::Snapshot; -use tower_lsp::jsonrpc::Result as LspResult; - -use super::protocol::rpc_error; -use super::transaction::ConfigurationRefreshHandles; -use crate::server::typeshed_status::{self, TypeshedFailure, TypeshedGeneration}; -use crate::server::LspServer; - -/// A fully gated candidate held outside the active root map until the client -/// accepts the matching configuration edit. -pub(super) struct StagedGeneration { - previous: Option, - candidate: Arc, -} - -pub(super) enum WatchedStageError { - Busy(&'static str), - Failed(TypeshedFailure), -} - -impl WatchedStageError { - pub(super) fn rpc_error(&self) -> tower_lsp::jsonrpc::Error { - match self { - Self::Busy(message) => rpc_error("typeshedAcquisitionBusy", message), - Self::Failed(failure) => rpc_error(failure.rpc_code(), failure.reason()), - } - } - - pub(super) fn into_failure(self) -> Option { - match self { - Self::Busy(_) => None, - Self::Failed(failure) => Some(failure), - } - } -} - -impl StagedGeneration { - pub(super) fn candidate(&self) -> &Arc { - &self.candidate - } - - pub(super) async fn activate(self, server: &LspServer, root: &Path) { - let status = self.candidate.status.clone(); - publish_generation( - &server.typeshed_generations, - &server.client, - root, - TypeshedGeneration::Ready(self.candidate), - ) - .await; - typeshed_status::show_high_warnings(&server.client, &status).await; - } - - pub(super) async fn activate_with(self, handles: &ConfigurationRefreshHandles, root: &Path) { - let status = self.candidate.status.clone(); - publish_generation( - &handles.typeshed_generations, - &handles.client, - root, - TypeshedGeneration::Ready(self.candidate), - ) - .await; - typeshed_status::show_high_warnings(&handles.client, &status).await; - } - - pub(super) async fn block(self, server: &LspServer, root: &Path, reason: &str) { - publish_generation( - &server.typeshed_generations, - &server.client, - root, - TypeshedGeneration::Blocked { - failure: TypeshedFailure::acquisition(reason), - }, - ) - .await; - } - - pub(super) async fn block_with( - self, - handles: &ConfigurationRefreshHandles, - root: &Path, - reason: &str, - ) { - publish_generation( - &handles.typeshed_generations, - &handles.client, - root, - TypeshedGeneration::Blocked { - failure: TypeshedFailure::acquisition(reason), - }, - ) - .await; - } - - pub(super) async fn rollback(self, server: &LspServer, root: &Path) { - let generation = self - .previous - .unwrap_or_else(|| TypeshedGeneration::Blocked { - failure: TypeshedFailure::acquisition("configuration edit was not applied"), - }); - publish_generation( - &server.typeshed_generations, - &server.client, - root, - generation, - ) - .await; - } -} - -/// Stage a candidate only when one of the six source-policy settings changed. -pub(super) async fn stage_configuration_change( - server: &LspServer, - root: &Path, - before: &BasiliskConfig, - after: &BasiliskConfig, -) -> LspResult> { - if !typeshed_policy_changed(before, after) { - return Ok(None); - } - let previous = begin_acquiring(server, root).await?; - if let Some(candidate) = pinned_active_candidate(previous.as_ref(), before, after) { - return Ok(Some(StagedGeneration { - previous, - candidate, - })); - } - match acquire(root, after, after.typeshed_cache.unwrap_or(true)).await { - Ok(candidate) => Ok(Some(StagedGeneration { - previous, - candidate, - })), - Err(error) => { - restore_after_failure(server, root, previous, error.reason()).await; - Err(rpc_error(error.rpc_code(), error.reason())) - } - } -} - -/// Reclassify the already-active immutable generation when `Pin current` is -/// the only policy change. The bytes and commit are already gate-accepted, so -/// downloading the identical archive again would add failure modes without -/// changing the selected source. -fn pinned_active_candidate( - previous: Option<&TypeshedGeneration>, - before: &BasiliskConfig, - after: &BasiliskConfig, -) -> Option> { - let requested = after.typeshed_commit.as_deref()?; - if before.typeshed_commit.is_some() - || before.typeshed_path.is_some() - || after.typeshed_path.is_some() - || before.typeshed_url != after.typeshed_url - || before.typeshed_cache_path != after.typeshed_cache_path - || before.typeshed_cache != after.typeshed_cache - || before.typeshed_verify != after.typeshed_verify - { - return None; - } - let active = previous.and_then(TypeshedGeneration::ready_snapshot)?; - if active - .status - .commit - .map(|commit| commit.to_hex()) - .as_deref() - != Some(requested) - { - return None; - } - - let mut candidate = (**active).clone(); - match &candidate.identity { - basilisk_stubs::typeshed::source::SourceIdentity::Commit { commit, .. } => { - candidate.identity = basilisk_stubs::typeshed::source::SourceIdentity::Commit { - commit: *commit, - pinned: true, - }; - candidate.status.active_source = - basilisk_stubs::typeshed::source::SourceKind::ExactCommit; - candidate - .status - .warnings - .retain(|warning| warning.code != "UNPINNED"); - } - basilisk_stubs::typeshed::source::SourceIdentity::Bundled { .. } => { - candidate.status.active_source = basilisk_stubs::typeshed::source::SourceKind::Bundled; - candidate.status.warnings.clear(); - } - basilisk_stubs::typeshed::source::SourceIdentity::Custom { .. } => return None, - } - Some(Arc::new(candidate)) -} - -/// Cache-bypassing one-run refresh used by the closed `AcquireFresh` action. -pub(super) async fn acquire_fresh( - server: &LspServer, - root: &Path, - config: &BasiliskConfig, -) -> Result { - let previous = begin_acquiring_with(&server.typeshed_generations, &server.client, root) - .await - .map_err(WatchedStageError::Busy)?; - match acquire(root, config, false).await { - Ok(candidate) => Ok(StagedGeneration { - previous, - candidate, - }), - Err(error) => Err(WatchedStageError::Failed(error)), - } -} - -/// Stage an already-observed on-disk configuration change. Unlike an editor -/// transaction, failure cannot restore the previous generation because the -/// source document has already changed, so the root becomes explicitly -/// blocked and its old snapshot is never rebound to the new policy. -pub(super) async fn stage_watched_configuration_change( - handles: &ConfigurationRefreshHandles, - root: &Path, - before: &BasiliskConfig, - after: &BasiliskConfig, -) -> Result, WatchedStageError> { - if !typeshed_policy_changed(before, after) { - return Ok(None); - } - let _previous = begin_acquiring_with(&handles.typeshed_generations, &handles.client, root) - .await - .map_err(WatchedStageError::Busy)?; - acquire(root, after, after.typeshed_cache.unwrap_or(true)) - .await - .map(|candidate| { - Some(StagedGeneration { - previous: None, - candidate, - }) - }) - .map_err(WatchedStageError::Failed) -} - -pub(super) async fn publish_failure( - handles: &ConfigurationRefreshHandles, - root: &Path, - failure: TypeshedFailure, -) { - publish_generation( - &handles.typeshed_generations, - &handles.client, - root, - TypeshedGeneration::Blocked { failure }, - ) - .await; -} - -async fn begin_acquiring(server: &LspServer, root: &Path) -> LspResult> { - begin_acquiring_with(&server.typeshed_generations, &server.client, root) - .await - .map_err(|message| rpc_error("typeshedAcquisitionBusy", message)) -} - -async fn begin_acquiring_with( - generations: &tokio::sync::RwLock, - client: &tower_lsp::Client, - root: &Path, -) -> Result, &'static str> { - let acquiring = TypeshedGeneration::Acquiring; - let previous = { - let mut generations = generations.write().await; - replace_with_acquiring(&mut generations, root)? - }; - typeshed_status::notify_generation(client, root, &acquiring).await; - Ok(previous) -} - -fn replace_with_acquiring( - generations: &mut typeshed_status::TypeshedGenerations, - root: &Path, -) -> Result, &'static str> { - if matches!(generations.get(root), Some(TypeshedGeneration::Acquiring)) { - return Err("Typeshed acquisition is already in progress"); - } - Ok(generations.insert(root.to_path_buf(), TypeshedGeneration::Acquiring)) -} - -async fn restore_after_failure( - server: &LspServer, - root: &Path, - previous: Option, - error: &str, -) { - let generation = previous.unwrap_or_else(|| TypeshedGeneration::Blocked { - failure: TypeshedFailure::acquisition(error), - }); - publish_generation( - &server.typeshed_generations, - &server.client, - root, - generation, - ) - .await; -} - -async fn publish_generation( - generations: &tokio::sync::RwLock, - client: &tower_lsp::Client, - root: &Path, - generation: TypeshedGeneration, -) { - let _ = generations - .write() - .await - .insert(root.to_path_buf(), generation.clone()); - typeshed_status::notify_generation(client, root, &generation).await; -} - -async fn acquire( - root: &Path, - config: &BasiliskConfig, - use_cache: bool, -) -> Result, TypeshedFailure> { - let workspace = super::watch::workspace_config_for_basilisk(root, config); - let cache_path = workspace.typeshed_cache_path.clone(); - let mut request = - crate::config::typeshed_request(&workspace).map_err(TypeshedFailure::acquisition)?; - request.use_cache = use_cache; - tokio::task::spawn_blocking(move || { - let manager = basilisk_stubs::typeshed::runtime::production_manager(request, cache_path) - .map_err(|error| TypeshedFailure::acquisition(error.to_string()))?; - manager - .snapshot() - .map_err(|error| TypeshedFailure::from_selection(&error)) - }) - .await - .map_err(|_join_error| TypeshedFailure::acquisition("Typeshed acquisition task failed"))? -} - -fn typeshed_policy_changed(before: &BasiliskConfig, after: &BasiliskConfig) -> bool { - before.typeshed_path != after.typeshed_path - || before.typeshed_commit != after.typeshed_commit - || before.typeshed_url != after.typeshed_url - || before.typeshed_cache_path != after.typeshed_cache_path - || before.typeshed_cache != after.typeshed_cache - || before.typeshed_verify != after.typeshed_verify -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::{replace_with_acquiring, typeshed_policy_changed, WatchedStageError}; - use crate::server::typeshed_status::{TypeshedFailure, TypeshedGenerations}; - use basilisk_config::BasiliskConfig; - use basilisk_stubs::typeshed::gittree::Oid; - use basilisk_stubs::typeshed::selector::{BackendError, SelectionError}; - - #[test] - fn candidate_staging_is_limited_to_the_six_typeshed_settings() { - let before = BasiliskConfig::default(); - let mut rule_only = before.clone(); - rule_only.python_version = Some("3.12".to_owned()); - assert!(!typeshed_policy_changed(&before, &rule_only)); - - let mut source = before.clone(); - source.typeshed_cache = Some(false); - assert!(typeshed_policy_changed(&before, &source)); - } - - #[test] - fn concurrent_acquisition_cannot_overwrite_the_active_candidate() { - let root = Path::new("/workspace"); - let mut generations = TypeshedGenerations::new(); - let first = replace_with_acquiring(&mut generations, root); - assert!(first.is_ok()); - let second = replace_with_acquiring(&mut generations, root); - assert_eq!( - second.err(), - Some("Typeshed acquisition is already in progress") - ); - } - - #[test] - fn license_drift_retains_its_typed_rpc_category() { - let Ok(commit) = Oid::from_hex("0123456789012345678901234567890123456789") else { - return; - }; - let failure = TypeshedFailure::from_selection(&SelectionError::Exact { - commit, - reason: BackendError::LicenseChanged, - }); - assert_eq!(failure.rpc_code(), "typeshedLicenseChanged"); - } - - #[test] - fn failed_fresh_acquisition_carries_failure_instead_of_a_previous_generation() { - let error = WatchedStageError::Failed(TypeshedFailure::acquisition("fresh failed")); - let failure = error.into_failure(); - assert_eq!( - failure.as_ref().map(TypeshedFailure::reason), - Some("fresh failed") - ); - } -} diff --git a/crates/basilisk-lsp/src/configuration_editor/typeshed_resolution.rs b/crates/basilisk-lsp/src/configuration_editor/typeshed_resolution.rs new file mode 100644 index 00000000..b32bd5e2 --- /dev/null +++ b/crates/basilisk-lsp/src/configuration_editor/typeshed_resolution.rs @@ -0,0 +1,204 @@ +//! Root-keyed local Typeshed resolution — never a download. +//! +//! Implements [STUBRES-TYPESHED-PIN] activation for the LSP: resolving a +//! source is a local store/bundle read ([STUBRES-TYPESHED-OFFLINE]), so a +//! configuration change computes the NEXT terminal generation off the message +//! loop and publishes nothing until the matching edit has actually landed +//! ([LSPCFGED-TYPESHED]). There is no acquiring state, no busy error, and no +//! rollback — the previous generation keeps serving analysis until its +//! terminal replacement is swapped in atomically. + +use std::path::Path; +use std::sync::Arc; + +use basilisk_config::{BasiliskConfig, ConfigDocument}; +use basilisk_stubs::typeshed::snapshot::Snapshot; +use tower_lsp::jsonrpc::Result as LspResult; + +use super::transaction::ConfigurationRefreshHandles; +use crate::server::typeshed_status::{self, TypeshedFailure, TypeshedGeneration}; +use crate::server::LspServer; + +/// The next terminal generation, computed locally and held privately until +/// the matching configuration edit lands. Dropping it publishes nothing — +/// which is exactly what a rejected edit requires. +pub(super) struct StagedResolution { + next: TypeshedGeneration, +} + +impl StagedResolution { + /// The candidate snapshot when the next generation is Ready. + pub(super) fn candidate(&self) -> Option<&Arc> { + self.next.ready_snapshot() + } + + /// Atomically replace the root's generation and notify clients. Elevated + /// source warnings surface here, once, on activation. + pub(super) async fn publish(self, handles: &ConfigurationRefreshHandles, root: &Path) { + let next = self.next; + let status = next.ready_status().cloned(); + let _ = handles + .typeshed_generations + .write() + .await + .insert(root.to_path_buf(), next.clone()); + typeshed_status::notify_generation(&handles.client, root, &next).await; + if let Some(status) = status { + typeshed_status::show_high_warnings(&handles.client, &status).await; + } + } +} + +/// Compute the next generation only when one of the three Typeshed source +/// settings changed. A valid-but-missing pin is VALID configuration: the +/// result is a `NoSource` generation, never a request error. +pub(super) async fn stage_configuration_change( + root: &Path, + before: &BasiliskConfig, + after: &BasiliskConfig, +) -> Option { + if !typeshed_policy_changed(before, after) { + return None; + } + Some(StagedResolution { + next: resolve(root, after).await, + }) +} + +/// Resolve one root's checker configuration to its terminal generation. +pub(crate) async fn resolve(root: &Path, config: &BasiliskConfig) -> TypeshedGeneration { + resolve_workspace(super::watch::workspace_config_for_basilisk(root, config)).await +} + +/// Resolve a workspace configuration to its terminal generation — a fast +/// local store/bundle read on a blocking thread, never the network. +pub(crate) async fn resolve_workspace( + config: crate::config::WorkspaceConfig, +) -> TypeshedGeneration { + let outcome = tokio::task::spawn_blocking(move || { + let request = + crate::config::typeshed_request(&config).map_err(TypeshedFailure::resolution)?; + basilisk_stubs::typeshed::runtime::production_manager(request) + .snapshot() + .map_err(|error| TypeshedFailure::from_selection(&error)) + }) + .await + .unwrap_or_else(|_join_error| { + Err(TypeshedFailure::resolution( + "Typeshed resolution task failed", + )) + }); + match outcome { + Ok(snapshot) => TypeshedGeneration::Ready(snapshot), + Err(failure) => { + tracing::warn!( + code = failure.rpc_code(), + reason = failure.reason(), + "Typeshed source is unavailable; the root resolves to NoSource" + ); + TypeshedGeneration::NoSource { failure } + } + } +} + +/// Re-resolve one root locally and activate the outcome: publish the terminal +/// generation, then run the shared refresh tail. Used after a user-invoked +/// download completes ([LSPCFGED-TYPESHED-DOWNLOAD]). +pub(crate) async fn resolve_and_activate( + server: &LspServer, + root: &Path, + document: &ConfigDocument, +) -> LspResult<()> { + let staged = StagedResolution { + next: resolve(root, &document.config).await, + }; + let candidate = staged.candidate().cloned(); + let handles = server.refresh_handles(); + staged.publish(&handles, root).await; + super::transaction::refresh_with_document_and_typeshed( + &handles, + root, + "typeshedDownloadActivate", + document, + candidate.as_ref(), + ) + .await +} + +/// Whether a configuration change touches the Typeshed source policy — the +/// whole surface is three keys ([LSPCFGED-TYPESHED]). +fn typeshed_policy_changed(before: &BasiliskConfig, after: &BasiliskConfig) -> bool { + before.typeshed_path != after.typeshed_path + || before.typeshed_commit != after.typeshed_commit + || before.typeshed_store_path != after.typeshed_store_path +} + +#[cfg(test)] +mod tests { + use basilisk_config::BasiliskConfig; + + use super::{stage_configuration_change, typeshed_policy_changed}; + + #[test] + fn staging_is_limited_to_the_three_typeshed_settings() { + let before = BasiliskConfig::default(); + let mut rule_only = before.clone(); + rule_only.python_version = Some("3.12".to_owned()); + assert!(!typeshed_policy_changed(&before, &rule_only)); + + let mut pin = before.clone(); + pin.typeshed_commit = Some("83c2518a9e6abbda0c44592c3483de459198f887".to_owned()); + assert!(typeshed_policy_changed(&before, &pin)); + + let mut store = before.clone(); + store.typeshed_store_path = Some(std::path::PathBuf::from("stores/typeshed")); + assert!(typeshed_policy_changed(&before, &store)); + } + + /// [LSPCFGED-TYPESHED]: a non-typeshed configuration change stages + /// NOTHING — no resolution runs, no status is published, so the editor + /// cannot flicker through any intermediate state. + #[tokio::test] + async fn unrelated_configuration_change_stages_nothing() { + let before = BasiliskConfig::default(); + let mut after = before.clone(); + after.python_version = Some("3.13".to_owned()); + let staged = + stage_configuration_change(std::path::Path::new("/workspace"), &before, &after).await; + assert!(staged.is_none()); + } + + /// A valid-but-missing pin stages a terminal `NoSource` generation + /// instead of failing the transaction: the config is valid, the machine + /// just does not hold that commit ([STUBRES-TYPESHED-PIN]). + #[tokio::test] + async fn missing_pin_stages_a_terminal_no_source_generation() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let store = std::env::temp_dir().join(format!( + "basilisk_resolution_empty_store_{}_{unique}", + std::process::id() + )); + let before = BasiliskConfig::default(); + let mut after = before.clone(); + after.typeshed_commit = Some("0123456789012345678901234567890123456789".to_owned()); + after.typeshed_store_path = Some(store.clone()); + let staged = + stage_configuration_change(std::path::Path::new("/workspace"), &before, &after).await; + let Some(staged) = staged else { + unreachable!("a pin change must stage a resolution"); + }; + assert!(staged.candidate().is_none()); + let state = staged.next.status_state(); + assert_eq!( + state.lifecycle, + crate::configuration_editor::model::TypeshedLifecycle::NoSource + ); + assert!(state + .no_source_reason + .is_some_and(|reason| reason.contains("NO SOURCE") + && reason.contains("0123456789012345678901234567890123456789"))); + let _ = std::fs::remove_dir_all(store); + } +} diff --git a/crates/basilisk-lsp/src/configuration_editor/watch.rs b/crates/basilisk-lsp/src/configuration_editor/watch.rs index caebc13e..9796aaa3 100644 --- a/crates/basilisk-lsp/src/configuration_editor/watch.rs +++ b/crates/basilisk-lsp/src/configuration_editor/watch.rs @@ -109,61 +109,27 @@ pub(crate) async fn refresh_root_from_disk( let document = handles.configuration_editor.effective_document(root); let result = match document { Ok(document) => { - match super::typeshed_acquisition::stage_watched_configuration_change( - handles, + let staged = super::typeshed_resolution::stage_configuration_change( root, &before, &document.config, ) - .await - { - Ok(Some(staged)) => { - let refreshed = super::transaction::refresh_with_document_and_typeshed( - handles, - root, - reason, - &document, - Some(staged.candidate()), - ) - .await; - match refreshed { - Ok(()) => { - staged.activate_with(handles, root).await; - Ok(()) - } - Err(error) => { - let cleanup = super::transaction::refresh_with_document( - handles, - root, - "typeshedWatchedConfigurationBlocked", - &document, - ) - .await; - staged - .block_with(handles, root, "configuration refresh failed") - .await; - if let Err(cleanup_error) = cleanup { - tracing::warn!(root = %root.display(), error = %cleanup_error, "failed to clear analysis after watched Typeshed activation failure"); - } - Err(error) - } - } - } - Ok(None) => { - super::transaction::refresh_with_document(handles, root, reason, &document) - .await - } - Err(error) => { - let rpc_error = error.rpc_error(); - let refresh = - super::transaction::refresh_with_document(handles, root, reason, &document) - .await; - if let Some(failure) = error.into_failure() { - super::typeshed_acquisition::publish_failure(handles, root, failure).await; - } - refresh.and(Err(rpc_error)) - } + .await; + let candidate = staged + .as_ref() + .and_then(super::typeshed_resolution::StagedResolution::candidate) + .cloned(); + if let Some(staged) = staged { + staged.publish(handles, root).await; } + super::transaction::refresh_with_document_and_typeshed( + handles, + root, + reason, + &document, + candidate.as_ref(), + ) + .await } Err(error) => Err(super::protocol::config_error(error)), }; @@ -309,16 +275,16 @@ pub(super) fn workspace_config_for_basilisk( } }); config.typeshed_commit.clone_from(&basilisk.typeshed_commit); - config.typeshed_url.clone_from(&basilisk.typeshed_url); - config.typeshed_cache_path = basilisk.typeshed_cache_path.as_ref().map(|path| { + config.typeshed_store_path = basilisk.typeshed_store_path.as_ref().map(|path| { if path.is_absolute() { path.clone() } else { root.join(path) } }); - config.typeshed_cache = basilisk.typeshed_cache.unwrap_or(true); - config.typeshed_verify = basilisk.typeshed_verify.unwrap_or(true); + // The supplied checker config is authoritative for all three Typeshed + // keys, so a stale on-disk type error must not leak into resolution. + config.typeshed_configuration_error = None; if basilisk.python_version.is_some() { config.python_version.clone_from(&basilisk.python_version); } diff --git a/crates/basilisk-lsp/src/hover/access.rs b/crates/basilisk-lsp/src/hover/access.rs new file mode 100644 index 00000000..fdb828a0 --- /dev/null +++ b/crates/basilisk-lsp/src/hover/access.rs @@ -0,0 +1,219 @@ +//! Implements [LSPARCH-FEATURES-HOVER]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-HOVER +//! +//! How the identifier under the cursor is reached, and what type the value +//! before the dot has. +//! +//! `imported_symbols` is a flat, module-agnostic map: a plain `import os` +//! publishes every member of `os` under its own bare name, so typeshed's +//! `error = OSError` occupies the key `"error"`. Answering `logger.error(...)` +//! from that key is a category error — the map may only be consulted for a +//! name the module actually *binds*. This module draws that line. + +use basilisk_resolver::{ImportInfo, ImportKind, ResolvedModule, Span}; + +use crate::util::{annotation_text, find_definition_by_name, SymbolHit}; + +/// How the identifier under the cursor is reached. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Access { + /// A free name — `getLogger(...)`, `MyClass`, `count`. Module-level + /// bindings and imported names both apply. + Free, + /// A member access — `logger.error`, `os.getcwd`, `" ".join`. + Member { + /// The receiver when it is a simple name; `None` when the receiver is + /// a literal or a compound expression (`" ".join`, `f().x`). + receiver: Option, + }, +} + +impl Access { + /// The receiver name, when this is a member access on a named receiver. + pub(crate) fn receiver(&self) -> Option<&str> { + match self { + Self::Free => None, + Self::Member { receiver } => receiver.as_deref(), + } + } + + /// Whether this access reaches the identifier through a `.`. + pub(crate) const fn is_member(&self) -> bool { + matches!(self, Self::Member { .. }) + } +} + +/// Classify the access at `byte_offset`. +/// +/// The identifier is a member access exactly when the first non-whitespace +/// character before it is a `.` — independent of whether the receiver is a +/// name, so `" ".join` is classified as a member access too. +pub(crate) fn access_at(source: &str, byte_offset: usize) -> Access { + let before = source + .get(..byte_offset.min(source.len())) + .unwrap_or(source); + let stripped = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_'); + let Some(before_dot) = stripped.trim_end().strip_suffix('.') else { + return Access::Free; + }; + let receiver: String = before_dot + .trim_end() + .chars() + .rev() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect::>() + .into_iter() + .rev() + .collect(); + Access::Member { + receiver: (!receiver.is_empty()).then_some(receiver), + } +} + +/// The external symbol a module-qualified access names, e.g. `os.getcwd`. +/// +/// The flat `imported_symbols` entry is trusted only when it came from a file +/// that one of `receiver`'s plain imports actually resolved to. Without that +/// check `logging`'s and `os`'s same-named exports are indistinguishable, and +/// whichever import was processed last answers for both. +pub(crate) fn module_member<'a>( + resolved: &'a ResolvedModule, + receiver: &str, + member: &str, +) -> Option<&'a basilisk_resolver::scope::ExternalSymbol> { + let symbol = resolved.imported_symbols.get(member)?; + module_imports_for(resolved, receiver) + .any(|import| import.resolved_path.as_deref() == Some(symbol.source_path.as_path())) + .then_some(symbol) +} + +/// The plain imports that bind `receiver` as a module object. +/// +/// `import os` and `import os.path as p` both bind a module; `import os.path` +/// binds only the leading `os` segment. +fn module_imports_for<'a>( + resolved: &'a ResolvedModule, + receiver: &'a str, +) -> impl Iterator { + resolved.imports.iter().filter(move |import| { + import.kind == ImportKind::Plain + && (import.names.iter().any(|name| name == receiver) + || import.module == receiver + || (import.names.is_empty() && import.module.split('.').next() == Some(receiver))) + }) +} + +/// Find the innermost class that encloses `offset`. +/// +/// A `self.` expression always sits inside a method body, which always +/// follows its own `def` — so the enclosing class is the one owning the +/// NEAREST PRECEDING method start, not the first method in the file +/// (regression: members of the file's first class leaked into every later +/// class). +pub(crate) fn enclosing_class( + resolved: &ResolvedModule, + offset: usize, +) -> Option<&basilisk_resolver::scope::ClassInfo> { + let func = resolved + .functions + .iter() + .filter(|f| f.class_name.is_some() && f.def_span.start_usize() <= offset) + .max_by_key(|f| f.def_span.start_usize())?; + let class_name = func.class_name.as_ref()?; + resolved.classes.iter().find(|c| &c.name == class_name) +} + +/// The name of the type a value receiver has. +/// +/// Resolved from the receiver's own declaration only — its annotation, the +/// literal it was assigned, or the return type of the call that produced it +/// (`logger = logging.getLogger(...)` → `Logger`). Nothing is guessed: an +/// unresolvable receiver yields `None` so hover stays silent rather than +/// answering for the wrong symbol ([LSPARCH-FEATURES-HOVER]). +/// +/// The `bool` reports a `LiteralString`-compatible receiver, which selects the +/// `LiteralString` overloads of built-in `str` methods. +pub(crate) fn receiver_type_name( + resolved: &ResolvedModule, + source: &str, + receiver: &str, +) -> Option<(String, bool)> { + let (annotation_span, rhs_kind, rhs_span) = match find_definition_by_name(resolved, receiver)? { + SymbolHit::Variable(var) => (var.annotation_span, Some(&var.rhs_kind), var.rhs_span), + SymbolHit::Parameter { param, .. } => (param.annotation_span, None, None), + _ => return None, + }; + if let Some(annotation) = annotation_text(annotation_span, source) { + let literal = annotation == "LiteralString" || annotation == "typing.LiteralString"; + return Some(( + if literal { + "str".to_owned() + } else { + annotation + }, + literal, + )); + } + let inferred = crate::util::rhs_type_display(rhs_kind?); + if inferred.is_empty() { + return call_return_type(resolved, rhs_span).map(|name| (name, false)); + } + let literal = + inferred == "str" && matches!(rhs_kind, Some(basilisk_resolver::RhsKind::StrLiteral)); + Some((inferred, literal)) +} + +/// The declared return type of the call that produced a variable. +/// +/// The resolver already recorded the call site — its callee and receiver come +/// from the AST, never from re-scanning source text. +fn call_return_type(resolved: &ResolvedModule, rhs_span: Option) -> Option { + let span = rhs_span?; + let call = resolved.calls.iter().find(|call| call.span == span)?; + let declared = match &call.receiver { + None => free_callee_return_type(resolved, &call.callee), + Some(basilisk_resolver::scope::CallReceiver::Name(module)) => { + module_member(resolved, module, &call.callee)? + .type_annotation + .clone() + } + Some(_) => None, + }?; + bare_type_name(&declared) +} + +/// The return type declared for a free callee: a local class constructor, a +/// local function's return annotation, or an imported declaration. +fn free_callee_return_type(resolved: &ResolvedModule, callee: &str) -> Option { + if resolved.classes.iter().any(|class| class.name == callee) { + return Some(callee.to_owned()); + } + if let Some(func) = resolved + .functions + .iter() + .find(|func| func.name == callee && func.class_name.is_none()) + { + if let Some(annotation) = annotation_text(func.return_annotation_span, &resolved.source) { + return Some(annotation); + } + } + let symbol = resolved.imported_symbols.get(callee)?; + match symbol.kind { + basilisk_resolver::scope::ExternalSymbolKind::Class => Some(symbol.name.clone()), + _ => symbol.type_annotation.clone(), + } +} + +/// Reduce a declared type to the bare class name hover can look up. +/// +/// `logging.Logger` → `Logger`, `"Logger"` → `Logger`, `list[int]` → `list`. +/// A union or any other compound form has no single declaring class, so it +/// yields `None` rather than an arbitrary member of it. +fn bare_type_name(declared: &str) -> Option { + let trimmed = declared.trim().trim_matches(['\'', '"']).trim(); + let head = trimmed.split('[').next().unwrap_or(trimmed).trim(); + let bare = head.rsplit('.').next().unwrap_or(head); + let plain = !bare.is_empty() + && bare.chars().all(|c| c.is_alphanumeric() || c == '_') + && !bare.starts_with(|c: char| c.is_ascii_digit()); + plain.then(|| bare.to_owned()) +} diff --git a/crates/basilisk-lsp/src/hover/members.rs b/crates/basilisk-lsp/src/hover/members.rs index 0d6a0b01..6aeebef3 100644 --- a/crates/basilisk-lsp/src/hover/members.rs +++ b/crates/basilisk-lsp/src/hover/members.rs @@ -1,71 +1,190 @@ //! Implements [LSPARCH-FEATURES-HOVER]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-HOVER //! Implements the shared-declaration consumer half of [TYPESHEDRT-ACCEPTANCE-HOVER]. //! -//! Dot-access member hover lookups: methods inherited from external -//! (stub/py.typed) base classes (GitHub #287), builtin-typed receivers like -//! `" ".join(...)` (GitHub #288), and the class-constructor hint (GitHub #289). - -use std::fmt::Write as _; +//! Member-access hover: `receiver.member`. +//! +//! The receiver decides the answer. It may name an imported module +//! (`os.getcwd`), a class (`Model.model_validate` — GitHub #287), or a value +//! whose type comes from its annotation, its literal, or the call that +//! produced it (`logger = logging.getLogger(...)` → `Logger`). A receiver +//! nothing can type yields no hover: answering from the flat `imported_symbols` +//! map by bare name would name whichever module happened to export the same +//! word last. use basilisk_resolver::ResolvedModule; -use crate::util::{find_definition_by_name, identifier_at_offset, SymbolHit}; +use super::access::{self, Access}; +use super::render::{SymbolCard, SymbolKind}; +use crate::util::identifier_at_offset; -/// Hover markdown for `Receiver.member` where `member` lives on an external -/// class — the receiver itself or a (transitive) base of a local class. +/// Hover markdown for the member access at `byte_offset`. /// -/// Implements the dot-access member lookup for GitHub #287: methods inherited -/// from stub/py.typed base classes have no local definition, so the standard -/// symbol lookups find nothing. -pub(super) fn external_member_hover( +/// The first resolution that names a real declaration wins: the module the +/// receiver binds, a class in the local hierarchy, an external (stub or +/// `py.typed`) class, then a built-in type. +pub(super) fn member_hover( resolved: &ResolvedModule, source: &str, byte_offset: usize, + access: &Access, ) -> Option { let member = identifier_at_offset(source, byte_offset)?; - let receiver = crate::completion::prefix::dot_receiver(source, byte_offset)?; - let (class, method) = find_external_member(resolved, &receiver, &member)?; + let receiver = access.receiver(); + let class_name = + receiver.and_then(|receiver| receiver_class_name(resolved, source, byte_offset, receiver)); + + receiver + .and_then(|receiver| module_member_hover(resolved, receiver, &member)) + .or_else(|| local_member_hover(resolved, class_name.as_deref(), &member)) + .or_else(|| external_member_hover(resolved, receiver, class_name.as_deref(), &member)) + .or_else(|| builtin_member_hover(resolved, source, byte_offset, &member)) +} + +/// The class whose members a receiver exposes. +/// +/// `self`/`cls` expose the enclosing class; a receiver naming a local class +/// exposes that class's own members (`Model.model_validate`); anything else is +/// a value, typed from its own declaration. +fn receiver_class_name( + resolved: &ResolvedModule, + source: &str, + byte_offset: usize, + receiver: &str, +) -> Option { + if matches!(receiver, "self" | "cls") { + return access::enclosing_class(resolved, byte_offset).map(|class| class.name.clone()); + } + if resolved.classes.iter().any(|class| class.name == receiver) { + return Some(receiver.to_owned()); + } + access::receiver_type_name(resolved, source, receiver).map(|(name, _)| name) +} + +/// Hover for `module.member`, e.g. `os.getcwd`. +fn module_member_hover(resolved: &ResolvedModule, receiver: &str, member: &str) -> Option { + let symbol = access::module_member(resolved, receiver, member)?; + super::external_symbol_card(symbol, Some(receiver.to_owned())).render() +} + +/// Hover for a member declared by a class in the *local* hierarchy — its own +/// method or attribute, or one inherited from a local base. +fn local_member_hover( + resolved: &ResolvedModule, + class_name: Option<&str>, + member: &str, +) -> Option { + let start = class_name?; + let hit = walk_class_hierarchy(resolved, start, |name| { + resolved + .functions + .iter() + .find(|func| func.name == member && func.class_name.as_deref() == Some(name)) + .map(crate::util::SymbolHit::Function) + .or_else(|| { + resolved + .classes + .iter() + .find(|class| class.name == name) + .and_then(|class| { + class + .attributes + .iter() + .find(|attr| attr.name == member) + .map(|attr| crate::util::SymbolHit::Attribute { class, attr }) + }) + }) + })?; + let signature = crate::util::format_type_signature(&hit, &resolved.source); + let docstring = match hit { + crate::util::SymbolHit::Function(func) => func.docstring.clone(), + _ => None, + }; + // `format_type_signature` already labels local symbols with their kind, so + // the card contributes no second prefix. + SymbolCard::new(None, signature) + .documented(docstring) + .render() +} + +/// Hover markdown for a member declared on an external (stub or `py.typed`) +/// class — the receiver's own class, or a (transitive) base of it. +/// +/// Implements the dot-access member lookup for GitHub #287: methods inherited +/// from stub base classes have no local definition, so the standard symbol +/// lookups find nothing. Every overload of the member is shown, matching the +/// built-in path. +fn external_member_hover( + resolved: &ResolvedModule, + receiver: Option<&str>, + class_name: Option<&str>, + member: &str, +) -> Option { + let (class, methods) = receiver + .and_then(|receiver| find_external_member(resolved, receiver, member)) + .or_else(|| class_name.and_then(|name| find_external_member(resolved, name, member)))?; // Stub signatures render as `def name(...)`; qualify with the class name - // to match the local-method hover style `(method) def Class.name(...)`. - let qualified = method.signature.strip_prefix("def ").map_or_else( - || method.signature.clone(), - |rest| format!("def {}.{rest}", class.name), - ); - let mut md = format!("```python\n(method) {qualified}\n```"); - if let Some(label) = class - .provenance - .and_then(basilisk_stubs::TypeProvenance::hover_label) - { - let _ = write!(md, "\n\n*{label}*"); + // so the hover names the type that declares the member. + let signatures = methods + .iter() + .map(|method| { + method.signature.strip_prefix("def ").map_or_else( + || method.signature.clone(), + |rest| format!("def {}.{rest}", class.name), + ) + }) + .collect(); + SymbolCard { + kind: Some(SymbolKind::Method), + signatures, + ..SymbolCard::default() } - Some(md) + .documented(methods.iter().find_map(|method| method.docstring.clone())) + .declared_in( + declaring_module(resolved, class.source_path.as_path()), + class.provenance.as_ref(), + Some(class.source_path.as_path()), + ) + .render() } -/// Find `member` on an external class reachable from `receiver`. +/// Find `member` on an external class reachable from `start`. /// -/// `receiver` may name an imported class directly, or a local class whose -/// (transitive) bases include one. +/// `start` may name an imported class directly, or a local class whose +/// (transitive) bases include one. Returns every overload of the member. fn find_external_member<'a>( resolved: &'a ResolvedModule, - receiver: &str, + start: &'a str, member: &str, ) -> Option<( &'a basilisk_resolver::scope::ExternalSymbol, - &'a basilisk_resolver::scope::ExternalMethod, + Vec<&'a basilisk_resolver::scope::ExternalMethod>, )> { use basilisk_resolver::scope::ExternalSymbolKind; - walk_class_hierarchy(resolved, receiver, |class_name| { + walk_class_hierarchy(resolved, start, |class_name| { let ext = resolved.imported_symbols.get(class_name)?; if ext.kind != ExternalSymbolKind::Class { return None; } - let method = ext.methods.iter().find(|m| m.name == member)?; - Some((ext, method)) + let methods: Vec<_> = ext.methods.iter().filter(|m| m.name == member).collect(); + (!methods.is_empty()).then_some((ext, methods)) }) } +/// The module an external declaration was read from, for the origin line. +/// +/// Matched by resolved file, not by name: a class reached through a receiver's +/// type (`Logger` from `logging.getLogger(...)`) is never itself a bound name, +/// so a name lookup would find nothing. +fn declaring_module(resolved: &ResolvedModule, source_path: &std::path::Path) -> Option { + resolved + .imports + .iter() + .find(|import| import.resolved_path.as_deref() == Some(source_path)) + .map(|import| import.module.clone()) +} + /// Find the `__init__` a class would run: its own, else the nearest one in /// its local base chain (GitHub #289). pub(super) fn find_class_init<'a>( @@ -107,6 +226,14 @@ fn walk_class_hierarchy<'r, T>( .map(|base| base.split('[').next().unwrap_or(base)), ); } + if let Some(external) = resolved.imported_symbols.get(class_name) { + queue.extend( + external + .bases + .iter() + .map(|base| base.split('[').next().unwrap_or(base)), + ); + } } None } @@ -114,31 +241,32 @@ fn walk_class_hierarchy<'r, T>( /// Hover markdown for `recv.member` where `recv` is a builtin-typed receiver. /// Every overload comes from the structured declaration indexed from the /// active snapshot's real `builtins.pyi` body (GitHub #288). -pub(super) fn builtin_member_hover( +fn builtin_member_hover( resolved: &ResolvedModule, source: &str, byte_offset: usize, + member: &str, ) -> Option { - let member = identifier_at_offset(source, byte_offset)?; - let (class, declarations) = - builtin_member_declarations(resolved, source, byte_offset, &member)?; + let (class, declarations) = builtin_member_declarations(resolved, source, byte_offset, member)?; let signatures = declarations .iter() .map(|declaration| basilisk_stubs::render_stub_signature(declaration)) .map(|signature| { format!( - "(method) def {}.{rest}", + "def {}.{rest}", class.declaration.name, rest = signature.strip_prefix("def ").unwrap_or(&signature) ) }) - .collect::>() - .join("\n"); - let mut markdown = format!("```python\n{signatures}\n```"); - if let Some(label) = class.provenance.hover_label() { - let _ = write!(markdown, "\n\n*{label}* — `{}`", class.source_identity); - } - Some(markdown) + .collect(); + let mut card = SymbolCard { + kind: Some(SymbolKind::Method), + signatures, + ..SymbolCard::default() + }; + card.provenance = class.provenance.hover_label().map(str::to_owned); + card.source_path = Some(class.source_identity.clone()); + card.render() } /// Active structured declarations for the built-in member at an editor offset. @@ -186,7 +314,7 @@ pub(crate) fn dot_receiver_builtin_type( } c if c.is_alphanumeric() || c == '_' => { let receiver = crate::completion::prefix::dot_receiver(source, byte_offset)?; - variable_builtin_type(resolved, source, &receiver) + access::receiver_type_name(resolved, source, &receiver) } _ => None, } @@ -204,34 +332,3 @@ fn str_literal_receiver(receiver_text: &str, quote: char) -> bool { .trim_end_matches(['r', 'R']) .ends_with(['b', 'B']) } - -/// The builtin type of a named variable or parameter: its annotation when it -/// names a builtin type, else its inferred right-hand-side type. -fn variable_builtin_type( - resolved: &ResolvedModule, - source: &str, - receiver: &str, -) -> Option<(String, bool)> { - let (annotation_span, rhs_kind) = match find_definition_by_name(resolved, receiver)? { - SymbolHit::Variable(var) => (var.annotation_span, Some(&var.rhs_kind)), - SymbolHit::Parameter { param, .. } => (param.annotation_span, None), - _ => return None, - }; - if let Some(annotation) = crate::util::annotation_text(annotation_span, source) { - let literal = annotation == "LiteralString" || annotation == "typing.LiteralString"; - let type_name = if literal { - "str".to_owned() - } else { - annotation - }; - return Some((type_name, literal)); - } - let inferred = crate::util::rhs_type_display(rhs_kind?); - if inferred.is_empty() { - None - } else { - let literal = - inferred == "str" && matches!(rhs_kind, Some(basilisk_resolver::RhsKind::StrLiteral)); - Some((inferred, literal)) - } -} diff --git a/crates/basilisk-lsp/src/hover/mod.rs b/crates/basilisk-lsp/src/hover/mod.rs index d2925f9e..795429c0 100644 --- a/crates/basilisk-lsp/src/hover/mod.rs +++ b/crates/basilisk-lsp/src/hover/mod.rs @@ -11,6 +11,8 @@ use std::fmt::Write as _; use basilisk_resolver::{ImportInfo, ImportResolution, PackageDepKind, ResolvedModule}; use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind}; +use render::{SymbolCard, SymbolKind}; + use crate::util::{ find_definition_by_name, find_symbol_at_offset, format_type_signature, identifier_at_offset, SymbolHit, @@ -18,10 +20,16 @@ use crate::util::{ /// Compute hover information at a byte offset. /// -/// Searches definition sites first, then tries name-based lookup for -/// reference sites (call sites, variable uses). Also shows import resolution -/// details when the cursor is on an import statement, and any diagnostics -/// covering the cursor position. +/// A definition site (the cursor directly on a symbol's `name_span`) always +/// answers for itself. Otherwise the answer depends on how the identifier is +/// reached: a member access (`logger.error`) is resolved through its receiver, +/// while a free name is resolved against the module's own bindings. The two +/// must not be mixed — `imported_symbols` is keyed by bare name, so consulting +/// it for a member would answer `logger.error` with whichever imported module +/// happened to export the word `error`. +/// +/// Import resolution details are added when the cursor is on an import +/// statement, and any diagnostics covering the cursor position are appended. #[must_use] pub fn hover_at( resolved: &ResolvedModule, @@ -31,82 +39,15 @@ pub fn hover_at( ) -> Option { let mut sections: Vec = Vec::new(); - // 1. Definition site: cursor directly on a symbol's name_span. + // 1. Definition site: cursor directly on a symbol's name_span. This is + // span-exact, so it answers for a member's own declaration too + // (`self.attr = ...`). let hit = find_symbol_at_offset(resolved, byte_offset); - // 2. Reference site: cursor on an identifier, look up by name. - let hit = hit.or_else(|| { - let name = identifier_at_offset(source, byte_offset)?; - find_definition_by_name(resolved, &name) - }); - if let Some(ref hit) = hit { push_symbol_sections(resolved, source, hit, &mut sections); - } - - // 2b. Imported symbol with no local definition (e.g. a function/class from a - // `.pyi` stub or a py.typed package): show its signature/type from - // cross-module resolution so stub types surface on hover. When cross-module - // resolution has not (yet) populated `imported_symbols`, fall back to the - // import declaration itself, which is always available from the same-file - // parse — so hovering a usage of an imported name is deterministic and never - // races cross-file indexing. - if hit.is_none() { - if let Some(name) = identifier_at_offset(source, byte_offset) { - let mut pushed = false; - if let Some(ext_sym) = resolved.imported_symbols.get(&name) { - let mut md = if let Some(sig) = &ext_sym.signature { - format!("```python\n{sig}\n```") - } else if let Some(ty) = &ext_sym.type_annotation { - format!("```python\n{name}: {ty}\n```") - } else { - String::new() - }; - if let Some(label) = ext_sym - .provenance - .and_then(basilisk_stubs::TypeProvenance::hover_label) - { - if !md.is_empty() { - md.push_str("\n\n"); - } - let _ = write!(md, "*{label}*"); - } - if !md.is_empty() { - sections.push(md); - pushed = true; - } - // An imported class also shows its constructor, resolved from the - // real `.pyi` via the flattened MRO methods — its own or inherited - // `__init__`/`__new__` (GitHub #289). Never a hand table. - if ext_sym.kind == basilisk_resolver::scope::ExternalSymbolKind::Class { - for ctor in external_constructor_signatures(ext_sym) { - sections.push(format!("```python\n{ctor}\n```")); - pushed = true; - } - } - } - if !pushed { - if let Some(imp) = crate::util::find_import_by_bound_name(resolved, &name) { - let sig = format_type_signature(&SymbolHit::Import(imp), source); - sections.push(format!("```python\n{sig}\n```")); - } - } - } - } - - // 2c. Dot-access on a member of an external class (GitHub #287): e.g. - // `Model.model_validate(...)` where `Model` subclasses an imported stub - // class. Nothing local or top-level matched, so resolve the receiver to an - // external class — directly or through local base chains — and show the - // member's signature from the stub. Failing that, type the receiver as a - // builtin (`" ".join(...)` — GitHub #288) and use the curated builtin - // method signatures. - if hit.is_none() && sections.is_empty() { - if let Some(md) = external_member_hover(resolved, source, byte_offset) - .or_else(|| builtin_member_hover(resolved, source, byte_offset)) - { - sections.push(md); - } + } else { + push_reference_sections(resolved, source, byte_offset, &mut sections); } // 3. Import resolution details when the cursor is on an import statement. @@ -144,6 +85,100 @@ pub fn hover_at( }) } +/// Push the hover sections for a *reference* to a symbol — a use, not its +/// declaration. +/// +/// A member access answers through its receiver; a free name answers from the +/// module's own bindings. Keeping the two apart is what stops a plain +/// `import os` — which publishes every member of `os` into `imported_symbols` +/// under its bare name — from answering `logger.error(...)` with `os.error`. +fn push_reference_sections( + resolved: &ResolvedModule, + source: &str, + byte_offset: usize, + sections: &mut Vec, +) { + let access = access::access_at(source, byte_offset); + if access.is_member() { + if let Some(md) = members::member_hover(resolved, source, byte_offset, &access) { + sections.push(md); + } + return; + } + let Some(name) = identifier_at_offset(source, byte_offset) else { + return; + }; + if let Some(hit) = find_definition_by_name(resolved, &name) { + push_symbol_sections(resolved, source, &hit, sections); + return; + } + push_imported_name_sections(resolved, source, &name, sections); +} + +/// Push the sections for a free name that only cross-module resolution knows: +/// a function or class from a `.pyi` stub or a `py.typed` package. +/// +/// When cross-module resolution has not (yet) populated `imported_symbols`, +/// this falls back to the import declaration itself, which is always available +/// from the same-file parse — so hovering a usage of an imported name is +/// deterministic and never races cross-file indexing (GitHub #200). +fn push_imported_name_sections( + resolved: &ResolvedModule, + source: &str, + name: &str, + sections: &mut Vec, +) { + let mut pushed = false; + if let Some(ext_sym) = resolved.imported_symbols.get(name) { + let module = crate::util::find_import_by_bound_name(resolved, name) + .map(|import| import.module.clone()); + if let Some(md) = external_symbol_card(ext_sym, module).render() { + sections.push(md); + pushed = true; + } + // An imported class also shows its constructor, resolved from the + // real `.pyi` via the flattened MRO methods — its own or inherited + // `__init__`/`__new__` (GitHub #289). Never a hand table. + if ext_sym.kind == basilisk_resolver::scope::ExternalSymbolKind::Class { + for ctor in external_constructor_signatures(ext_sym) { + sections.push(format!("```python\n(method) {ctor}\n```")); + pushed = true; + } + } + } + if !pushed { + if let Some(imp) = crate::util::find_import_by_bound_name(resolved, name) { + let sig = format_type_signature(&SymbolHit::Import(imp), source); + sections.push(format!("```python\n{sig}\n```")); + } + } +} + +/// The hover card for an imported symbol: what it is, its declared shape, its +/// documentation, and where the declaration was read from. +pub(super) fn external_symbol_card( + symbol: &basilisk_resolver::scope::ExternalSymbol, + module: Option, +) -> SymbolCard { + let signature = symbol.signature.clone().or_else(|| { + symbol + .type_annotation + .as_ref() + .map(|ty| format!("{}: {ty}", symbol.name)) + }); + SymbolCard { + kind: SymbolKind::of_external(&symbol.kind), + signatures: signature.into_iter().collect(), + ..SymbolCard::default() + } + .documented(symbol.docstring.clone()) + .declared_in( + module, + symbol.provenance.as_ref(), + Some(symbol.source_path.as_path()), + ) +} + /// Push the hover sections for a resolved symbol hit: its signature, the /// constructor hint for classes (GitHub #289), its docstring, and the /// provenance annotation for imported symbols. @@ -176,14 +211,19 @@ fn push_symbol_sections( sections.push(ds.to_owned()); } - // Show provenance annotation for imported symbols. + // Show the provenance annotation for imported symbols. The name must + // actually be *bound* by an import: a plain `import os` publishes every + // member of `os` into `imported_symbols` under its bare name, so a local + // `def error(...)` would otherwise be labelled as coming from Typeshed. let hit_name = match hit { SymbolHit::Function(f) => Some(f.name.as_str()), SymbolHit::Class(c) => Some(c.name.as_str()), SymbolHit::Variable(v) => Some(v.name.as_str()), _ => None, }; - if let Some(name) = hit_name { + if let Some(name) = + hit_name.filter(|name| crate::util::find_import_by_bound_name(resolved, name).is_some()) + { if let Some(ext_sym) = resolved.imported_symbols.get(name) { if let Some(label) = ext_sym .provenance @@ -356,9 +396,11 @@ fn configure_severity_link(code: &str) -> Option { )) } +pub(crate) mod access; pub(crate) mod members; +mod render; -use members::{builtin_member_hover, external_member_hover, find_class_init}; +use members::find_class_init; #[cfg(test)] #[expect( diff --git a/crates/basilisk-lsp/src/hover/render.rs b/crates/basilisk-lsp/src/hover/render.rs new file mode 100644 index 00000000..288be00f --- /dev/null +++ b/crates/basilisk-lsp/src/hover/render.rs @@ -0,0 +1,157 @@ +//! Implements [LSPARCH-FEATURES-HOVER]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-HOVER +//! +//! Markdown rendering for one hovered symbol. +//! +//! Every hover answers the same three questions in the same order: *what kind +//! of thing is this*, *what is its exact shape*, and *where did that come +//! from*. [`SymbolCard`] is the one place those are laid out, so a signature +//! from a local `def`, a `.pyi` stub, and the bundled Typeshed snapshot all +//! read identically. Unknown pieces are omitted rather than fabricated. + +use std::fmt::Write as _; + +/// What a hovered symbol is, as the label that precedes its signature. +/// +/// The label is the vocabulary the rest of the LSP already uses for local +/// symbols, so an imported symbol is described exactly like a local one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SymbolKind { + Function, + Method, + Class, + Variable, +} + +impl SymbolKind { + const fn label(self) -> &'static str { + match self { + Self::Function => "function", + Self::Method => "method", + Self::Class => "class", + Self::Variable => "variable", + } + } + + /// The kind of an external symbol, given how it is being accessed. + pub(crate) const fn of_external( + kind: &basilisk_resolver::scope::ExternalSymbolKind, + ) -> Option { + use basilisk_resolver::scope::ExternalSymbolKind as External; + match kind { + External::Function => Some(Self::Function), + External::Class => Some(Self::Class), + External::Variable => Some(Self::Variable), + // A re-export is a forwarding edge, not a symbol shape of its own. + External::ReExport => None, + } + } +} + +/// The declaration's location as a reader wants to see it. +/// +/// A snapshot body is addressed by a logical `typeshed:/…` URI whose +/// identity is a content digest — precise, but 40 characters of noise in a +/// hover bubble, and the provenance annotation beside it already says which +/// snapshot is active. The snapshot-relative path is what identifies the +/// declaration to a reader. A real file keeps its full path, which is +/// actionable: it can be opened. +fn readable_source_path(path: &std::path::Path) -> String { + let display = path.display().to_string(); + display + .strip_prefix("typeshed:") + .and_then(|rest| rest.split_once('/')) + .map_or(display.clone(), |(_identity, relative)| relative.to_owned()) +} + +/// Everything hover knows about one symbol. +#[derive(Debug, Default)] +pub(crate) struct SymbolCard { + /// What the symbol is. `None` when nothing determined it. + pub(crate) kind: Option, + /// One rendered line per declaration — several for an overload set. + pub(crate) signatures: Vec, + /// The symbol's own prose documentation. + pub(crate) docstring: Option, + /// The module the declaration was read from, e.g. `logging`. + pub(crate) module: Option, + /// Where the type information came from, e.g. `(typeshed)`. + pub(crate) provenance: Option, + /// The file the declaration was read from. + pub(crate) source_path: Option, +} + +impl SymbolCard { + /// A card for a single declaration. + pub(crate) fn new(kind: Option, signature: String) -> Self { + Self { + kind, + signatures: vec![signature], + ..Self::default() + } + } + + /// Attach the origin of the declaration: its module, its provenance + /// annotation, and the file it was read from. + pub(crate) fn declared_in( + mut self, + module: Option, + provenance: Option<&basilisk_stubs::TypeProvenance>, + source_path: Option<&std::path::Path>, + ) -> Self { + self.module = module; + self.provenance = provenance + .and_then(|p| basilisk_stubs::TypeProvenance::hover_label(*p)) + .map(str::to_owned); + self.source_path = source_path.map(readable_source_path); + self + } + + /// Attach the symbol's documentation. + pub(crate) fn documented(mut self, docstring: Option) -> Self { + self.docstring = docstring; + self + } + + /// Render the card as the Markdown sections of a hover bubble. + /// + /// Returns `None` when there is no signature to show — a card with only an + /// origin says nothing a user can act on. + pub(crate) fn render(&self) -> Option { + if self.signatures.is_empty() { + return None; + } + let prefix = self + .kind + .map(|kind| format!("({}) ", kind.label())) + .unwrap_or_default(); + let body = self + .signatures + .iter() + .map(|signature| format!("{prefix}{signature}")) + .collect::>() + .join("\n"); + let mut markdown = format!("```python\n{body}\n```"); + if let Some(docstring) = &self.docstring { + let _ = write!(markdown, "\n\n{docstring}"); + } + if let Some(origin) = self.origin_line() { + let _ = write!(markdown, "\n\n{origin}"); + } + Some(markdown) + } + + /// The trailing attribution line: module, provenance, and source file. + fn origin_line(&self) -> Option { + let mut parts: Vec = Vec::new(); + if let Some(module) = &self.module { + parts.push(format!("`{module}`")); + } + if let Some(provenance) = &self.provenance { + parts.push(format!("*{provenance}*")); + } + if let Some(path) = &self.source_path { + parts.push(format!("`{path}`")); + } + (!parts.is_empty()).then(|| parts.join(" — ")) + } +} diff --git a/crates/basilisk-lsp/src/hover/tests.rs b/crates/basilisk-lsp/src/hover/tests.rs index ebd779f8..eb231b1c 100644 --- a/crates/basilisk-lsp/src/hover/tests.rs +++ b/crates/basilisk-lsp/src/hover/tests.rs @@ -70,6 +70,7 @@ fn test_hover_on_imported_stub_symbol_shows_signature() { source_path: std::path::PathBuf::from("/venv/.../acme-stubs/__init__.pyi"), source_span: Span::new(0, 0), signature: Some("def fetch(url: str) -> bytes".to_owned()), + docstring: None, provenance: Some(basilisk_stubs::TypeProvenance::StubTier1), methods: Vec::new(), bases: Vec::new(), @@ -115,10 +116,12 @@ fn test_hover_on_imported_class_shows_inherited_constructor() { source_path: std::path::PathBuf::from("/typeshed/stdlib/unittest/mock.pyi"), source_span: Span::new(0, 0), signature: Some("class Mock".to_owned()), + docstring: None, provenance: Some(basilisk_stubs::TypeProvenance::StubTier1), methods: vec![ExternalMethod { name: "__init__".to_owned(), signature: "def __init__(spec: Any, side_effect: Any) -> None".to_owned(), + docstring: None, }], bases: vec!["CallableMixin".to_owned(), "NonCallableMock".to_owned()], metaclass: None, @@ -570,3 +573,101 @@ fn test_hover_infers_generic_type_args_for_dict_literal() { markup.value ); } + +/// A member access resolves through its receiver, so `self.attr` still finds +/// the attribute the enclosing class declares — the receiver-aware path must +/// not have traded one broken lookup for another. +/// +/// The attribute is declared in the class body: an attribute that only ever +/// appears as `self.x = ...` inside a method is not recorded by the resolver +/// at all (`ClassInfo::attributes` stays empty), so no hover consumer can +/// reach it. That gap predates the receiver-aware path and is not what this +/// test pins. +#[test] +fn test_hover_on_self_attribute_resolves_through_the_enclosing_class() { + let source = + "class Point:\n x: int = 0\n\n def show(self) -> None:\n print(self.x)\n"; + let resolved = parse_and_resolve(source); + + let offset = source.rfind("self.x").expect("the read must be present") + "self.".len(); + let hover = hover_at(&resolved, source, offset, &[]).expect("`self.x` must have hover"); + let HoverContents::Markup(markup) = hover.contents else { + panic!("expected Markup hover contents"); + }; + + assert!( + markup.value.contains("Point.x") && markup.value.contains("int"), + "hover must show the attribute the enclosing class declares: {}", + markup.value + ); +} + +/// A local method reached through a variable typed by its constructor call. +/// Nothing binds `instance` to `Greeter` except the call, so this only +/// resolves once the receiver is typed from the call site. +#[test] +fn test_hover_on_method_of_constructor_typed_receiver() { + let source = "class Greeter:\n def greet(self, name: str) -> str:\n return name\n\ninstance = Greeter()\nvalue = instance.greet(\"x\")\n"; + let resolved = parse_and_resolve(source); + + let offset = source.rfind("greet").expect("the call must be present") + 1; + let hover = hover_at(&resolved, source, offset, &[]).expect("the method call must have hover"); + let HoverContents::Markup(markup) = hover.contents else { + panic!("expected Markup hover contents"); + }; + + assert!( + markup.value.contains("Greeter.greet") && markup.value.contains("name: str"), + "hover must resolve the method through the receiver's constructed type: {}", + markup.value + ); +} + +/// A plain `import os` publishes every member of `os` into `imported_symbols` +/// under its bare name, so a *local* symbol that happens to share one of those +/// names was being labelled as coming from Typeshed. Provenance may only be +/// claimed for a name an import actually binds. +#[test] +fn test_hover_on_local_symbol_is_not_labelled_with_import_provenance() { + use basilisk_resolver::scope::{ExternalSymbol, ExternalSymbolKind}; + use basilisk_resolver::Span; + + let source = "import os\n\n\ndef error(message: str) -> None:\n print(message)\n\n\nerror(\"boom\")\n"; + let mut resolved = parse_and_resolve(source); + + // Exactly what a plain `import os` produces for typeshed's `error = OSError`. + let _ = resolved.imported_symbols.insert( + "error".to_owned(), + ExternalSymbol { + name: "error".to_owned(), + kind: ExternalSymbolKind::Variable, + type_annotation: Some("OSError".to_owned()), + source_path: std::path::PathBuf::from("typeshed:bundled/stdlib/os/__init__.pyi"), + source_span: Span::new(0, 0), + signature: None, + docstring: None, + provenance: Some(basilisk_stubs::TypeProvenance::StubTier1), + methods: Vec::new(), + bases: Vec::new(), + metaclass: None, + metaclass_calls: Vec::new(), + }, + ); + + let offset = source.rfind("error").expect("the call must be present") + 1; + let hover = hover_at(&resolved, source, offset, &[]).expect("the local call must have hover"); + let HoverContents::Markup(markup) = hover.contents else { + panic!("expected Markup hover contents"); + }; + + assert!( + markup.value.contains("def error(message: str)"), + "hover must show the local definition: {}", + markup.value + ); + assert!( + !markup.value.contains("(typeshed)"), + "a local symbol must not be attributed to an import that never bound it: {}", + markup.value + ); +} diff --git a/crates/basilisk-lsp/src/lib.rs b/crates/basilisk-lsp/src/lib.rs index 260f2fab..86ba62bb 100644 --- a/crates/basilisk-lsp/src/lib.rs +++ b/crates/basilisk-lsp/src/lib.rs @@ -61,6 +61,7 @@ pub mod symbols; pub mod test_discovery; pub mod type_definition; pub mod type_hierarchy; +pub(crate) mod typeshed_download; pub mod util; pub mod uv_commands; pub mod uv_failure; diff --git a/crates/basilisk-lsp/src/profiler/aggregator.rs b/crates/basilisk-lsp/src/profiler/aggregator.rs index 021dff39..bec171bc 100644 --- a/crates/basilisk-lsp/src/profiler/aggregator.rs +++ b/crates/basilisk-lsp/src/profiler/aggregator.rs @@ -135,6 +135,17 @@ fn is_runtime_scaffolding(filename: &str, function: &str) -> bool { { return true; } + // The stdlib thread-bootstrap spine. Every non-main thread starts inside + // `Thread._bootstrap`, so keeping it roots each thread's flame chart three + // rows above the code the user wrote, and — because the debugger's own + // housekeeping threads are started by the stdlib too — leaves a + // debugpy-only thread non-empty, so it survives as pure machinery instead + // of being dropped. Only the bootstrap trio qualifies: a genuine + // `threading` wait (`join`, `acquire`) is behaviour the user asked about, + // and a user's own `run` override lives in the user's file, not here. + if basename == "threading.py" { + return matches!(function, "_bootstrap" | "_bootstrap_inner" | "run"); + } normalized .split('/') .any(|segment| segment == "debugpy" || segment == "pydevd") diff --git a/crates/basilisk-lsp/src/server/init.rs b/crates/basilisk-lsp/src/server/init.rs index d7b12523..4b272edc 100644 --- a/crates/basilisk-lsp/src/server/init.rs +++ b/crates/basilisk-lsp/src/server/init.rs @@ -119,19 +119,14 @@ pub(super) async fn initialize( .and_then(parse_python_interpreter); (*server.python_interpreter.write().await).clone_from(&python_interpreter); - // Every root starts in an explicit non-ready generation. The - // `initialized` notification acquires one fully gated source before any - // workspace analysis starts, and publishes the matching root-keyed status. - { - let mut generations = server.typeshed_generations.write().await; - generations.clear(); - for root in &roots { - let _ = generations.insert( - root.clone(), - super::typeshed_status::TypeshedGeneration::Acquiring, - ); - } - } + // Resolve every root's terminal Typeshed generation BEFORE answering + // `initialize`: resolution is a local store/bundle read + // ([STUBRES-TYPESHED-OFFLINE]), so the initialize payload below carries + // real Ready/NoSource statuses and no client ever renders an + // intermediate state ([LSPCFGED-TYPESHED]). Statuses ride the payload; + // change notifications only flow for later generation changes. + server.typeshed_generations.write().await.clear(); + resolve_typeshed_for_roots(server, roots.clone(), false).await; // Load project-level checker config (pyproject.toml [tool.basilisk]) so // that rule severity overrides, per-module, and per-path settings match @@ -324,7 +319,6 @@ pub(super) async fn initialized(server: &LspServer) { .log_message(MessageType::INFO, "Basilisk LSP initialized") .await; - acquire_initial_typeshed(server).await; install_initial_search_paths(server).await; let statuses = server @@ -392,11 +386,10 @@ pub(super) async fn initialized(server: &LspServer) { match mode { // Implements [ANALYSIS-STARTUP-OPEN] AnalysisMode::OpenFilesOnly => { - // `didOpen` may arrive while the initial Typeshed generation is - // still Acquiring. The document handler preserves that buffer's - // authoritative text without analysing it; once acquisition has - // completed, this is the convergence point that analyses and - // publishes every deferred open file owned by a ready root. + // `didOpen` may arrive before this notification. The document + // handler preserves that buffer's authoritative text; this is the + // convergence point that analyses and publishes every deferred + // open file owned by a root with a Ready generation. let guard = server.index.read().await; let Some(index) = guard.as_ref() else { return }; let results = index.refresh_open_files_for_roots(&ready_roots); @@ -438,55 +431,32 @@ pub(super) async fn initialized(server: &LspServer) { spawn_initial_test_discovery(server); } -async fn acquire_typeshed_snapshot( - root: &std::path::Path, - config: crate::config::WorkspaceConfig, -) -> Result< - Arc, - super::typeshed_status::TypeshedFailure, -> { - let cache_path = config.typeshed_cache_path.clone(); - let request = crate::config::typeshed_request(&config) - .map_err(super::typeshed_status::TypeshedFailure::acquisition)?; - let manager = basilisk_stubs::typeshed::runtime::production_manager(request, cache_path) - .map_err(|error| super::typeshed_status::TypeshedFailure::acquisition(error.to_string()))?; - let result = tokio::task::spawn_blocking(move || manager.snapshot()) - .await - .map_err(|_join_error| { - super::typeshed_status::TypeshedFailure::acquisition("Typeshed acquisition task failed") - })? - .map_err(|error| super::typeshed_status::TypeshedFailure::from_selection(&error)); - if result.is_err() { - tracing::warn!(root = %root.display(), "initial Typeshed acquisition failed"); - } - result -} - -/// Acquire each root's immutable generation before the first scan. Identical -/// root policies may hit the same immutable cache entry, but each result is -/// still published under the owning root so later configuration changes can -/// replace one generation without cross-root bleed. -async fn acquire_initial_typeshed(server: &LspServer) { - let mut roots = server.workspace_roots.read().await.clone(); - roots.sort(); - acquire_typeshed_for_roots(server, roots).await; -} - -async fn acquire_typeshed_for_roots(server: &LspServer, roots: Vec) { +/// Resolve each root's terminal generation from local sources only — never +/// the network ([STUBRES-TYPESHED-OFFLINE]). Identical root policies may +/// resolve the same immutable store entry, but each result is still keyed by +/// its owning root so later configuration changes can replace one generation +/// without cross-root bleed. +async fn resolve_typeshed_for_roots( + server: &LspServer, + roots: Vec, + notify: bool, +) { let interpreter = server.python_interpreter.read().await.clone(); for root in roots { let mut config = crate::config::load_config(&root); config.python_interpreter.clone_from(&interpreter); - let generation = match acquire_typeshed_snapshot(&root, config).await { - Ok(snapshot) => super::typeshed_status::TypeshedGeneration::Ready(snapshot), - Err(failure) => super::typeshed_status::TypeshedGeneration::Blocked { failure }, - }; + let generation = crate::configuration_editor::resolve_workspace(config).await; + if generation.ready_snapshot().is_none() { + tracing::warn!(root = %root.display(), "Typeshed source is not on this machine; analysis will not run for this root"); + } let _ = server .typeshed_generations .write() .await .insert(root.clone(), generation.clone()); - super::typeshed_status::notify_generation(&server.client, &root, &generation).await; + if notify { + super::typeshed_status::notify_generation(&server.client, &root, &generation).await; + } } } @@ -717,22 +687,8 @@ pub(super) async fn did_change_workspace_folders( for root in &removed_roots { let _ = generations.remove(root); } - for root in &added_roots { - let _ = generations.insert( - root.clone(), - super::typeshed_status::TypeshedGeneration::Acquiring, - ); - } } - for root in &added_roots { - super::typeshed_status::notify_generation( - &server.client, - root, - &super::typeshed_status::TypeshedGeneration::Acquiring, - ) - .await; - } - acquire_typeshed_for_roots(server, added_roots).await; + resolve_typeshed_for_roots(server, added_roots, true).await; let interpreter = server.python_interpreter.read().await.clone(); let checker_config = updated_roots @@ -1626,7 +1582,7 @@ mod explicit_python_tests { } #[tokio::test] - async fn startup_acquires_custom_typeshed_before_analysis() { + async fn startup_resolves_custom_typeshed_before_analysis() { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |duration| duration.as_nanos()); @@ -1649,9 +1605,13 @@ mod explicit_python_tests { typeshed_path: Some(custom), ..crate::config::WorkspaceConfig::default() }; - let snapshot = acquire_typeshed_snapshot(&root, config).await; - assert!(snapshot.is_ok(), "custom startup acquisition: {snapshot:?}"); - let Ok(snapshot) = snapshot else { + let generation = crate::configuration_editor::resolve_workspace(config).await; + let snapshot = generation.ready_snapshot(); + assert!( + snapshot.is_some(), + "custom startup resolution: {generation:?}" + ); + let Some(snapshot) = snapshot.cloned() else { let _ = std::fs::remove_dir_all(&base); return; }; @@ -1696,7 +1656,7 @@ mod explicit_python_tests { let _ = std::fs::remove_dir_all(&base); } - /// [STUBRES-TYPESHED-ACQUIRE]: readiness follows the longest owning root; + /// [STUBRES-TYPESHED-OFFLINE]: readiness follows the longest owning root; /// a ready parent must not authorize analysis inside its blocked child. #[test] fn nested_workspace_typeshed_readiness_uses_the_longest_owner() { diff --git a/crates/basilisk-lsp/src/server/stub_handlers.rs b/crates/basilisk-lsp/src/server/stub_handlers.rs index 7ce603ec..6f841cc5 100644 --- a/crates/basilisk-lsp/src/server/stub_handlers.rs +++ b/crates/basilisk-lsp/src/server/stub_handlers.rs @@ -1,4 +1,4 @@ -//! Implements [STUBRES-CREATE-LOCAL]. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#stubres-create-local +//! Implements [STUBRES-CREATE-LOCAL]. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CREATE-LOCAL //! //! LSP command handler for `basilisk.stubs.createLocal`. //! diff --git a/crates/basilisk-lsp/src/server/typeshed_status.rs b/crates/basilisk-lsp/src/server/typeshed_status.rs index bfc8809c..af45b64b 100644 --- a/crates/basilisk-lsp/src/server/typeshed_status.rs +++ b/crates/basilisk-lsp/src/server/typeshed_status.rs @@ -1,8 +1,14 @@ //! Implements [STUBRES-TYPESHED-WARN] LSP routing. //! -//! Typeshed transport status is editor metadata. It is merged into the +//! Typeshed source status is editor metadata. It is merged into the //! initialize payload for persistent Service Info and elevated warnings are //! sent with `window/showMessage`; none of it is a Python diagnostic. +//! +//! There is **no acquiring state**: resolution is a local read +//! ([STUBRES-TYPESHED-OFFLINE]), so a root's generation is always terminal — +//! `Ready` or `NoSource`. The only long-running lifecycle is a user-invoked +//! download, which never replaces the generation until it has finished and +//! re-resolved locally ([LSPCFGED-TYPESHED-DOWNLOAD]). use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -20,19 +26,17 @@ use tower_lsp::Client; use crate::configuration_editor::model::{ TypeshedLicenseStatus, TypeshedLifecycle, TypeshedStatusChanged, TypeshedStatusState, }; -use crate::configuration_editor::snapshot_typeshed::status_projection; +use crate::configuration_editor::snapshot_typeshed::ready_projection; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TypeshedFailureKind { LicenseChanged, CustomUnavailable, - ExactUnavailable, - LatestUnavailable, - InconsistentIdentity, - AcquisitionFailed, + NoSource, + ResolutionFailed, } -/// One redacted terminal acquisition failure with the selection category kept +/// One redacted terminal resolution failure with the selection category kept /// intact for typed RPC/status projection. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TypeshedFailure { @@ -42,9 +46,9 @@ pub(crate) struct TypeshedFailure { impl TypeshedFailure { #[must_use] - pub(crate) fn acquisition(reason: impl Into) -> Self { + pub(crate) fn resolution(reason: impl Into) -> Self { Self { - kind: TypeshedFailureKind::AcquisitionFailed, + kind: TypeshedFailureKind::ResolutionFailed, reason: reason.into(), } } @@ -55,18 +59,11 @@ impl TypeshedFailure { // Custom sources provide user-managed terms, so they never project // the official Typeshed license as changed. SelectionError::Custom(_) => TypeshedFailureKind::CustomUnavailable, - SelectionError::Exact { reason, .. } if *reason == BackendError::LicenseChanged => { - TypeshedFailureKind::LicenseChanged - } - SelectionError::Exact { .. } => TypeshedFailureKind::ExactUnavailable, - SelectionError::LatestAndBundle { latest, bundle } - if *latest == BackendError::LicenseChanged - || *bundle == BackendError::LicenseChanged => - { + SelectionError::NoSource { reason, .. } if *reason == BackendError::LicenseChanged => { TypeshedFailureKind::LicenseChanged } - SelectionError::LatestAndBundle { .. } => TypeshedFailureKind::LatestUnavailable, - SelectionError::InconsistentIdentity => TypeshedFailureKind::InconsistentIdentity, + SelectionError::NoSource { .. } => TypeshedFailureKind::NoSource, + SelectionError::InconsistentIdentity => TypeshedFailureKind::ResolutionFailed, }; Self { kind, @@ -79,10 +76,8 @@ impl TypeshedFailure { match self.kind { TypeshedFailureKind::LicenseChanged => "typeshedLicenseChanged", TypeshedFailureKind::CustomUnavailable => "typeshedCustomUnavailable", - TypeshedFailureKind::ExactUnavailable => "typeshedExactUnavailable", - TypeshedFailureKind::LatestUnavailable => "typeshedLatestUnavailable", - TypeshedFailureKind::InconsistentIdentity => "typeshedInconsistentIdentity", - TypeshedFailureKind::AcquisitionFailed => "typeshedAcquisitionFailed", + TypeshedFailureKind::NoSource => "typeshedNoSource", + TypeshedFailureKind::ResolutionFailed => "typeshedResolutionFailed", } } @@ -100,20 +95,19 @@ impl TypeshedFailure { } } -/// One workspace root's acquisition generation. +/// One workspace root's terminal resolution generation. /// /// A candidate is never exposed as Ready until every activation gate passes. -/// Reacquisition replaces this value atomically; in-flight requests may keep -/// their old [`Arc`], while new analysis observes only the terminal -/// generation selected for its root. +/// A configuration change replaces this value atomically with the *next* +/// terminal generation — there is no intermediate state to render, which is +/// what keeps the editor free of blocking overlays ([LSPCFGED-TYPESHED]). #[derive(Debug, Clone)] pub(crate) enum TypeshedGeneration { - /// Acquisition is in progress; analysis for this root must not start. - Acquiring, /// One complete immutable source is active. Ready(Arc), - /// No candidate activated. The reason is redacted and safe for UI/MCP. - Blocked { failure: TypeshedFailure }, + /// The selected source is not on this machine; analysis does not run. + /// The reason is redacted and safe for UI/MCP. + NoSource { failure: TypeshedFailure }, } impl TypeshedGeneration { @@ -122,7 +116,7 @@ impl TypeshedGeneration { pub(crate) fn ready_snapshot(&self) -> Option<&Arc> { match self { Self::Ready(snapshot) => Some(snapshot), - Self::Acquiring | Self::Blocked { .. } => None, + Self::NoSource { .. } => None, } } @@ -136,19 +130,35 @@ impl TypeshedGeneration { #[must_use] pub(crate) fn status_state(&self) -> TypeshedStatusState { match self { - Self::Acquiring => status_projection(None), - Self::Ready(snapshot) => status_projection(Some(&snapshot.status)), - Self::Blocked { failure } => { - let mut state = status_projection(None); - state.lifecycle = TypeshedLifecycle::Blocked; - state.blocked_reason = Some(failure.reason().to_owned()); - state.license_status = failure.license_status(); - state - } + Self::Ready(snapshot) => ready_projection(&snapshot.status), + Self::NoSource { failure } => TypeshedStatusState { + lifecycle: TypeshedLifecycle::NoSource, + no_source_reason: Some(failure.reason().to_owned()), + active_source: None, + commit_identity: None, + license_status: failure.license_status(), + warnings: Vec::new(), + }, } } } +/// The transient status shown while a user-invoked download runs. The +/// generation map is untouched — the previous source keeps serving analysis — +/// so this state can only ever appear on the invoking control, never as a +/// panel-blocking mode ([LSPCFGED-TYPESHED-DOWNLOAD]). +#[must_use] +pub(crate) fn downloading_state(commit: Option<&str>) -> TypeshedStatusState { + TypeshedStatusState { + lifecycle: TypeshedLifecycle::Downloading, + no_source_reason: None, + active_source: None, + commit_identity: commit.map(str::to_owned), + license_status: TypeshedLicenseStatus::Unavailable, + warnings: Vec::new(), + } +} + /// Root-keyed generation map shared by the LSP's analysis and status surfaces. pub(crate) type TypeshedGenerations = BTreeMap; @@ -181,7 +191,7 @@ pub(super) fn experimental_payload( Value::Object(root) } -/// Typed terminal/acquiring status notification. +/// Typed terminal/downloading status notification. pub(crate) enum TypeshedStatusChangedNotification {} impl Notification for TypeshedStatusChangedNotification { @@ -195,6 +205,11 @@ pub(crate) async fn notify_generation( root: &Path, generation: &TypeshedGeneration, ) { + notify_status(client, root, generation.status_state()).await; +} + +/// Notify clients of one root's status DTO (terminal or download-transient). +pub(crate) async fn notify_status(client: &Client, root: &Path, status: TypeshedStatusState) { let Ok(root_uri) = Url::from_file_path(root) else { tracing::warn!(root = %root.display(), "cannot publish Typeshed status for non-file root"); return; @@ -202,7 +217,7 @@ pub(crate) async fn notify_generation( client .send_notification::(TypeshedStatusChanged { root_uri: root_uri.to_string(), - status: generation.status_state(), + status, }) .await; } @@ -232,32 +247,24 @@ fn object_or_empty(value: Option) -> Map { #[cfg(test)] mod tests { use basilisk_stubs::typeshed::gittree::Oid; - use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceKind, StatusWarning, Transport, - }; + use basilisk_stubs::typeshed::source::{LicenseStatus, SourceKind, StatusWarning}; use basilisk_stubs::typeshed::warning::{TypeshedWarning, UnpinnedKind}; use super::*; - fn fallback_status() -> TypeshedStatus { + fn bundled_default_status() -> TypeshedStatus { TypeshedStatus { active_source: SourceKind::Bundled, commit: Oid::from_hex("83c2518a9e6abbda0c44592c3483de459198f887").ok(), tree: Oid::from_hex("66408ffce2750980efc6da09e8a6652733f852e4").ok(), - transport: Transport::EmbeddedZip, license_status: LicenseStatus::Approved, license_reference: Some( "https://github.com/python/typeshed/blob/83c2518a9e6abbda0c44592c3483de459198f887/LICENSE" .to_owned(), ), - provenance: Provenance::BundleVetted, - signed_release: false, warnings: StatusWarning::list(&[ - TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled), - TypeshedWarning::DownloadFailed { - bundled_sha: "83c2518a9e6abbda0c44592c3483de459198f887".to_owned(), - }, - TypeshedWarning::Unverified, + TypeshedWarning::LicenseChanged, + TypeshedWarning::Unpinned(UnpinnedKind::BundledDefault), ]), } } @@ -267,7 +274,7 @@ mod tests { let Ok(mut snapshot) = basilisk_stubs::typeshed::bundle::bundled_snapshot() else { return; }; - snapshot.status = fallback_status(); + snapshot.status = bundled_default_status(); let generations = BTreeMap::from([( PathBuf::from("/workspace"), TypeshedGeneration::Ready(Arc::new(snapshot)), @@ -290,59 +297,87 @@ mod tests { ); assert_eq!( payload.pointer("/basilisk/typeshedStatuses/0/status/warnings/1/code"), - Some(&Value::String("DOWNLOAD FAILED".to_owned())) - ); - assert_eq!( - payload.pointer("/basilisk/typeshedStatuses/0/status/warnings/2/code"), - Some(&Value::String("UNVERIFIED".to_owned())) - ); - assert_eq!( - payload.pointer("/basilisk/typeshedStatuses/0/status/provenance/kind"), - Some(&Value::String("BundleVetted".to_owned())) - ); - assert_eq!( - payload.pointer("/basilisk/typeshedStatuses/0/status/transport/kind"), - Some(&Value::String("EmbeddedZip".to_owned())) - ); - assert_eq!( - payload.pointer("/basilisk/typeshedStatuses/0/status/signedRelease"), - Some(&Value::Bool(false)) + Some(&Value::String("LICENSE CHANGED".to_owned())) ); + // The retired trust-bijection fields must never reappear on the wire: + // the active source IS the trust story ([STUBRES-TYPESHED-WARN]). + for retired in ["transport", "provenance", "signedRelease", "blockedReason"] { + assert_eq!( + payload.pointer(&format!("/basilisk/typeshedStatuses/0/status/{retired}")), + None, + "retired wire field: {retired}" + ); + } } + /// [LSPCFGED-TYPESHED]: the lifecycle union has NO acquiring/blocked + /// panel state — a generation is always terminal, so there is nothing a + /// client could render as a blocking overlay between config changes. #[test] - fn blocked_generation_has_no_candidate_source_or_provenance() { - let state = TypeshedGeneration::Blocked { - failure: TypeshedFailure::acquisition("exact commit unavailable"), + fn generation_states_are_terminal_ready_or_no_source() { + let no_source = TypeshedGeneration::NoSource { + failure: TypeshedFailure::resolution("NO SOURCE — pin is not on this machine"), } .status_state(); - assert_eq!(state.lifecycle, TypeshedLifecycle::Blocked); + assert_eq!(no_source.lifecycle, TypeshedLifecycle::NoSource); assert_eq!( - state.blocked_reason.as_deref(), - Some("exact commit unavailable") + no_source.no_source_reason.as_deref(), + Some("NO SOURCE — pin is not on this machine") + ); + assert_eq!(no_source.license_status, TypeshedLicenseStatus::Unavailable); + assert!(no_source.active_source.is_none()); + assert!(no_source.commit_identity.is_none()); + + // Serde-level proof the retired states are gone from the wire union. + for retired in ["Acquiring", "Blocked"] { + let json = format!("{{\"kind\":\"{retired}\"}}"); + assert!( + serde_json::from_str::(&json).is_err(), + "retired lifecycle must not deserialize: {retired}" + ); + } + } + + /// [LSPCFGED-TYPESHED-DOWNLOAD]: the transient download status carries the + /// requested pin but never a source — it is button state, not a panel mode. + #[test] + fn downloading_state_is_transient_button_state() { + let state = downloading_state(Some("83c2518a9e6abbda0c44592c3483de459198f887")); + assert_eq!(state.lifecycle, TypeshedLifecycle::Downloading); + assert_eq!( + state.commit_identity.as_deref(), + Some("83c2518a9e6abbda0c44592c3483de459198f887") ); - assert_eq!(state.license_status, TypeshedLicenseStatus::Unavailable); assert!(state.active_source.is_none()); - assert!(state.commit_identity.is_none()); + assert!(state.no_source_reason.is_none()); } #[test] - fn exact_license_drift_projects_changed_while_custom_failure_does_not() { + fn no_source_license_drift_projects_changed_while_custom_failure_does_not() { let Ok(commit) = basilisk_stubs::typeshed::gittree::Oid::from_hex( "0123456789012345678901234567890123456789", ) else { return; }; - let exact = TypeshedFailure::from_selection(&SelectionError::Exact { + let drifted = TypeshedFailure::from_selection(&SelectionError::NoSource { commit, reason: BackendError::LicenseChanged, }); - let exact_state = TypeshedGeneration::Blocked { failure: exact }.status_state(); - assert_eq!(exact_state.license_status, TypeshedLicenseStatus::Changed); + assert_eq!(drifted.rpc_code(), "typeshedLicenseChanged"); + let drifted_state = TypeshedGeneration::NoSource { failure: drifted }.status_state(); + assert_eq!(drifted_state.license_status, TypeshedLicenseStatus::Changed); + + let missing = TypeshedFailure::from_selection(&SelectionError::NoSource { + commit, + reason: BackendError::Missing, + }); + assert_eq!(missing.rpc_code(), "typeshedNoSource"); + assert!(missing.reason().contains("NO SOURCE")); let custom = TypeshedFailure::from_selection(&SelectionError::Custom(BackendError::LicenseChanged)); - let custom_state = TypeshedGeneration::Blocked { failure: custom }.status_state(); + assert_eq!(custom.rpc_code(), "typeshedCustomUnavailable"); + let custom_state = TypeshedGeneration::NoSource { failure: custom }.status_state(); assert_eq!( custom_state.license_status, TypeshedLicenseStatus::Unavailable @@ -351,14 +386,11 @@ mod tests { #[test] fn show_message_projection_contains_only_high_warnings() { - let messages = high_warning_messages(&fallback_status()); - assert_eq!(messages.len(), 2); + let messages = high_warning_messages(&bundled_default_status()); + assert_eq!(messages.len(), 1); assert!(messages .first() - .is_some_and(|message| message.contains("DOWNLOAD FAILED"))); - assert!(messages - .get(1) - .is_some_and(|message| message.contains("UNVERIFIED"))); + .is_some_and(|message| message.contains("LICENSE CHANGED"))); assert!(messages.iter().all(|message| !message.contains("UNPINNED"))); let source = include_str!("typeshed_status.rs"); diff --git a/crates/basilisk-lsp/src/typeshed_download.rs b/crates/basilisk-lsp/src/typeshed_download.rs new file mode 100644 index 00000000..134cb550 --- /dev/null +++ b/crates/basilisk-lsp/src/typeshed_download.rs @@ -0,0 +1,222 @@ +//! User-invoked Typeshed downloads ([LSPCFGED-TYPESHED-DOWNLOAD]). +//! +//! Lives OUTSIDE the configuration editor ([TYPESHEDRT-SEGREGATION]): the +//! editor only reads and writes the three Typeshed keys, while this module is +//! the only LSP code that reaches the network — and it runs only when a +//! person invokes one of the two Download buttons. The root's generation map +//! is untouched while a download runs, so the active source keeps serving +//! analysis and the transient `Downloading` status exists only on the +//! invoking control, never as a panel-blocking mode. + +use std::path::{Path, PathBuf}; + +use basilisk_config::{BasiliskConfig, ConfigDocument, ConfigurationUpdate, TypeshedConfigKey}; +use basilisk_stubs::typeshed::gittree::Oid; +use basilisk_typeshed_fetch::{DownloadError, DownloadOutcome, DownloadPhase, GithubClient}; +use tower_lsp::jsonrpc::Result as LspResult; + +use crate::configuration_editor::rpc_error; +use crate::server::typeshed_status::{downloading_state, notify_status, TypeshedGeneration}; +use crate::server::LspServer; + +/// Download `python/typeshed@main` into the store and write the returned SHA +/// as the pin — the Download latest contract ([STUBRES-TYPESHED-DOWNLOAD]). +/// The pin write rides the same validated editor transaction as any other +/// configuration edit, which re-resolves locally and publishes the terminal +/// generation. +pub(crate) async fn download_latest_and_pin( + server: &LspServer, + root: &Path, + document: &ConfigDocument, +) -> LspResult<()> { + notify_status(&server.client, root, downloading_state(None)).await; + let result = match run_download(store_path_for(root, &document.config), None).await { + Ok(outcome) => crate::configuration_editor::apply_configuration_update( + server, + root, + &pin_update(&outcome), + "typeshedDownloadLatest", + ) + .await + .map(|_applied| ()), + Err(error) => Err(error), + }; + // Always settle on a terminal status so the invoking control's transient + // download state clears — even when the pin was already current and the + // transaction therefore staged nothing. + republish_terminal(server, root).await; + result +} + +/// Materialise the existing pin on a machine that never downloaded it. Writes +/// no configuration; on success the root re-resolves locally and activates. +pub(crate) async fn download_pinned( + server: &LspServer, + root: &Path, + document: &ConfigDocument, +) -> LspResult<()> { + if document.config.typeshed_path.is_some() { + return Err(rpc_error( + "typeshedActionUnavailable", + "a custom Typeshed folder has no commit to download", + )); + } + let Some(commit_hex) = document.config.typeshed_commit.clone() else { + return Err(rpc_error( + "typeshedActionUnavailable", + "no pinned commit to download — the unset pin resolves to the bundled source", + )); + }; + let commit = Oid::from_hex(&commit_hex).map_err(|_invalid_oid| { + rpc_error( + "invalidTypeshedSetting", + "typeshed-commit must be a full 40-character hexadecimal SHA", + ) + })?; + notify_status(&server.client, root, downloading_state(Some(&commit_hex))).await; + let result = match run_download(store_path_for(root, &document.config), Some(commit)).await { + Ok(_outcome) => { + crate::configuration_editor::resolve_and_activate(server, root, document).await + } + Err(error) => Err(error), + }; + republish_terminal(server, root).await; + result +} + +/// The workspace-resolved store override, when configured. `None` lets the +/// download component fall back to the canonical per-user store +/// ([STUBRES-TYPESHED-STORE]). +fn store_path_for(root: &Path, config: &BasiliskConfig) -> Option { + config.typeshed_store_path.as_ref().map(|path| { + if path.is_absolute() { + path.clone() + } else { + root.join(path) + } + }) +} + +async fn run_download( + store_path: Option, + commit: Option, +) -> LspResult { + tokio::task::spawn_blocking(move || { + let client = GithubClient::new(); + let progress = |phase: DownloadPhase| { + tracing::info!(?phase, "typeshed download progress"); + }; + match commit { + Some(commit) => { + basilisk_typeshed_fetch::download_commit(commit, store_path, &client, &progress) + } + None => basilisk_typeshed_fetch::download_latest(store_path, &client, &progress), + } + }) + .await + .map_err(|_join_error| rpc_error("typeshedDownloadFailed", "typeshed download task failed"))? + .map_err(|error| rpc_error(download_error_code(error), &error.to_string())) +} + +const fn download_error_code(error: DownloadError) -> &'static str { + match error { + DownloadError::LicenseChanged => "typeshedLicenseChanged", + DownloadError::Metadata + | DownloadError::Download + | DownloadError::Validation + | DownloadError::Store => "typeshedDownloadFailed", + } +} + +fn pin_update(outcome: &DownloadOutcome) -> ConfigurationUpdate { + let mut update = ConfigurationUpdate::default(); + let _ = update.typeshed.entries.insert( + TypeshedConfigKey::TypeshedCommit, + Some(outcome.commit.to_hex()), + ); + let _ = update + .typeshed + .entries + .insert(TypeshedConfigKey::TypeshedPath, None); + update +} + +/// Re-notify the root's current terminal status after a download settles. +async fn republish_terminal(server: &LspServer, root: &Path) { + let status = server + .typeshed_generations + .read() + .await + .get(root) + .map(TypeshedGeneration::status_state); + match status { + Some(status) => notify_status(&server.client, root, status).await, + None => { + tracing::warn!(root = %root.display(), "no Typeshed generation to republish after download"); + } + } +} + +#[cfg(test)] +mod tests { + use basilisk_stubs::typeshed::gittree::Oid; + use basilisk_typeshed_fetch::{DownloadError, DownloadOutcome}; + + use super::{download_error_code, pin_update, store_path_for}; + use basilisk_config::{BasiliskConfig, TypeshedConfigKey}; + + /// [STUBRES-TYPESHED-DOWNLOAD]: Download latest writes the returned SHA + /// as the pin and clears any custom folder — the two keys are mutually + /// exclusive, so the update must retire one while setting the other. + #[test] + fn download_latest_pin_update_sets_commit_and_clears_custom_path() { + let Ok(commit) = Oid::from_hex("0123456789012345678901234567890123456789") else { + return; + }; + let Ok(tree) = Oid::from_hex("abcdefabcdefabcdefabcdefabcdefabcdefabcd") else { + return; + }; + let update = pin_update(&DownloadOutcome { commit, tree }); + assert_eq!( + update + .typeshed + .entries + .get(&TypeshedConfigKey::TypeshedCommit), + Some(&Some("0123456789012345678901234567890123456789".to_owned())) + ); + assert_eq!( + update + .typeshed + .entries + .get(&TypeshedConfigKey::TypeshedPath), + Some(&None) + ); + assert!(!update + .typeshed + .entries + .contains_key(&TypeshedConfigKey::TypeshedStorePath)); + } + + #[test] + fn license_drift_keeps_its_typed_error_code() { + assert_eq!( + download_error_code(DownloadError::LicenseChanged), + "typeshedLicenseChanged" + ); + assert_eq!( + download_error_code(DownloadError::Metadata), + "typeshedDownloadFailed" + ); + } + + #[test] + fn relative_store_override_roots_at_the_workspace() { + let mut config = BasiliskConfig::default(); + assert!(store_path_for(std::path::Path::new("/workspace"), &config).is_none()); + config.typeshed_store_path = Some(std::path::PathBuf::from("stores/typeshed")); + assert_eq!( + store_path_for(std::path::Path::new("/workspace"), &config), + Some(std::path::PathBuf::from("/workspace/stores/typeshed")) + ); + } +} diff --git a/crates/basilisk-lsp/tests/bundled_mock_hover_tests.rs b/crates/basilisk-lsp/tests/bundled_mock_hover_tests.rs index 6252bc2f..dae6ffd5 100644 --- a/crates/basilisk-lsp/tests/bundled_mock_hover_tests.rs +++ b/crates/basilisk-lsp/tests/bundled_mock_hover_tests.rs @@ -13,50 +13,31 @@ use std::sync::Arc; -use basilisk_checker::imports::{ActiveTypeshed, ImportSearchPaths}; -use basilisk_checker::{ - cross_resolved_module, BasiliskDatabase, FileRegistry, ResolvedFile, SearchPathsInput, - SourceFile, WorkspaceFiles, -}; +use basilisk_checker::imports::ActiveTypeshed; use basilisk_lsp::hover::hover_at; use basilisk_stubs::types::{StubTarget, StubTargetPlatform}; use basilisk_stubs::typeshed::archive::{Archive, ArchiveEntry, ArchiveVfs}; use basilisk_stubs::typeshed::gittree::FileMode; use basilisk_stubs::typeshed::snapshot::Snapshot; -use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, Transport, TypeshedStatus, -}; +use basilisk_stubs::typeshed::source::{LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus}; use tower_lsp::lsp_types::HoverContents; fn resolve_with_snapshot( snapshot: Arc, source: &str, ) -> Arc { - let search_paths = ImportSearchPaths { - roots: Vec::new(), - extra_paths: Vec::new(), - stub_paths: Vec::new(), - workspace_members: Vec::new(), - site_packages: None, - registry: None, - typeshed_snapshot: Some(ActiveTypeshed::new( + let search_paths = basilisk_test_utils::typeshed_search_paths( + ActiveTypeshed::new( snapshot, Some(StubTarget { python_version: (3, 12), platform: StubTargetPlatform::Concrete("linux".to_owned()), }), - )), - }; - let database = BasiliskDatabase::default(); - let search_input = SearchPathsInput::new(&database, search_paths); - let workspace = WorkspaceFiles::new(&database, FileRegistry::default()); - let file = SourceFile::new(&database, "main.py".to_owned(), source.to_owned()); - let ResolvedFile::Resolved(resolved) = - cross_resolved_module(&database, file, search_input, workspace) - else { - panic!("the Mock fixture must parse and resolve"); - }; - Arc::clone(resolved) + ), + Vec::new(), + ); + basilisk_test_utils::cross_resolve(source, search_paths) + .expect("the Mock fixture must parse and resolve") } fn custom_mock_snapshot() -> Arc { @@ -80,11 +61,8 @@ fn custom_mock_snapshot() -> Arc { active_source: SourceKind::Custom, commit: None, tree: None, - transport: Transport::CustomPath, license_status: LicenseStatus::NotSupplied, license_reference: None, - provenance: Provenance::UserManaged, - signed_release: false, warnings: Vec::new(), }; Arc::new( diff --git a/crates/basilisk-lsp/tests/custom_typeshed_hover_tests.rs b/crates/basilisk-lsp/tests/custom_typeshed_hover_tests.rs index f90ae83f..a2179107 100644 --- a/crates/basilisk-lsp/tests/custom_typeshed_hover_tests.rs +++ b/crates/basilisk-lsp/tests/custom_typeshed_hover_tests.rs @@ -32,6 +32,7 @@ fn hover_on_custom_typeshed_symbol_shows_custom_annotation() -> Result<(), Strin source_path: std::path::PathBuf::from("/proj/ts/stdlib/os.pyi"), source_span: Span::new(0, 0), signature: Some("def uname() -> str".to_owned()), + docstring: None, provenance: Some(basilisk_stubs::TypeProvenance::StubCustomTypeshed), methods: Vec::new(), bases: Vec::new(), diff --git a/crates/basilisk-lsp/tests/custom_typeshed_workspace_tests.rs b/crates/basilisk-lsp/tests/custom_typeshed_workspace_tests.rs index efebca42..7133f271 100644 --- a/crates/basilisk-lsp/tests/custom_typeshed_workspace_tests.rs +++ b/crates/basilisk-lsp/tests/custom_typeshed_workspace_tests.rs @@ -81,8 +81,7 @@ fn lsp_threads_custom_typeshed_into_imported_symbols() { let mut search_paths = basilisk_lsp::import_resolver::search_paths_from_config(&roots, &config, None); let request = basilisk_lsp::config::typeshed_request(&config).expect("custom request"); - let manager = basilisk_stubs::typeshed::runtime::production_manager(request, None) - .expect("custom manager"); + let manager = basilisk_stubs::typeshed::runtime::production_manager(request); let snapshot = manager.snapshot().expect("custom snapshot"); search_paths.typeshed_snapshot = Some(basilisk_lsp::import_resolver::ActiveTypeshed::new( snapshot, diff --git a/crates/basilisk-lsp/tests/external_metaclass_constructor_tests.rs b/crates/basilisk-lsp/tests/external_metaclass_constructor_tests.rs index 0e3d4054..14522e3d 100644 --- a/crates/basilisk-lsp/tests/external_metaclass_constructor_tests.rs +++ b/crates/basilisk-lsp/tests/external_metaclass_constructor_tests.rs @@ -9,19 +9,13 @@ use std::sync::Arc; -use basilisk_checker::imports::{ActiveTypeshed, ImportSearchPaths}; -use basilisk_checker::{ - cross_resolved_module, BasiliskDatabase, FileRegistry, ResolvedFile, SearchPathsInput, - SourceFile, WorkspaceFiles, -}; +use basilisk_checker::imports::ActiveTypeshed; use basilisk_lsp::hover::hover_at; use basilisk_stubs::types::{StubTarget, StubTargetPlatform}; use basilisk_stubs::typeshed::archive::{Archive, ArchiveEntry, ArchiveVfs}; use basilisk_stubs::typeshed::gittree::FileMode; use basilisk_stubs::typeshed::snapshot::Snapshot; -use basilisk_stubs::typeshed::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, Transport, TypeshedStatus, -}; +use basilisk_stubs::typeshed::source::{LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus}; use tower_lsp::lsp_types::HoverContents; fn metaclass_snapshot() -> Arc { @@ -60,11 +54,8 @@ fn metaclass_snapshot() -> Arc { active_source: SourceKind::Custom, commit: None, tree: None, - transport: Transport::CustomPath, license_status: LicenseStatus::NotSupplied, license_reference: None, - provenance: Provenance::UserManaged, - signed_release: false, warnings: Vec::new(), }; Arc::new( @@ -79,31 +70,18 @@ fn metaclass_snapshot() -> Arc { } fn resolve(source: &str) -> Arc { - let search_paths = ImportSearchPaths { - roots: Vec::new(), - extra_paths: Vec::new(), - stub_paths: Vec::new(), - workspace_members: Vec::new(), - site_packages: None, - registry: None, - typeshed_snapshot: Some(ActiveTypeshed::new( + let search_paths = basilisk_test_utils::typeshed_search_paths( + ActiveTypeshed::new( metaclass_snapshot(), Some(StubTarget { python_version: (3, 12), platform: StubTargetPlatform::Concrete("linux".to_owned()), }), - )), - }; - let database = BasiliskDatabase::default(); - let search_input = SearchPathsInput::new(&database, search_paths); - let workspace = WorkspaceFiles::new(&database, FileRegistry::default()); - let file = SourceFile::new(&database, "main.py".to_owned(), source.to_owned()); - let ResolvedFile::Resolved(resolved) = - cross_resolved_module(&database, file, search_input, workspace) - else { - panic!("fixture source must parse and resolve"); - }; - Arc::clone(resolved) + ), + Vec::new(), + ); + basilisk_test_utils::cross_resolve(source, search_paths) + .expect("fixture source must parse and resolve") } fn hover_markdown( diff --git a/crates/basilisk-lsp/tests/hover_member_resolution_tests.rs b/crates/basilisk-lsp/tests/hover_member_resolution_tests.rs new file mode 100644 index 00000000..03dc01bc --- /dev/null +++ b/crates/basilisk-lsp/tests/hover_member_resolution_tests.rs @@ -0,0 +1,156 @@ +//! Implements [LSPARCH-FEATURES-HOVER]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-HOVER +//! Acceptance for the shared-declaration consumer half of [TYPESHEDRT-ACCEPTANCE-HOVER]. +//! +//! Hovering `receiver.member` must answer for **that member on that receiver**. +//! A plain `import os` publishes every member of `os` into the flat +//! `imported_symbols` map under its bare name, so typeshed's `error = OSError` +//! occupies the key `"error"`; hover used to answer `logger.error(...)` from +//! that key because it never looked at the receiver before the dot. +//! +//! These fixtures enter through the real salsa `cross_resolved_module` query +//! over the real bundled Typeshed snapshot — no hand-built `ExternalSymbol`. + +#![allow( + clippy::expect_used, + clippy::panic, + missing_docs, + reason = "end-to-end acceptance fixture" +)] + +use std::sync::Arc; + +use basilisk_checker::imports::ActiveTypeshed; +use basilisk_lsp::hover::hover_at; +use basilisk_stubs::types::{StubTarget, StubTargetPlatform}; +use basilisk_test_utils::{cross_resolve, typeshed_search_paths}; +use tower_lsp::lsp_types::HoverContents; + +/// A module that logs through a `logging.Logger` while also importing `os` — +/// the exact collision that made hover answer with `os.error`. +const LOGGER_SOURCE: &str = concat!( + "import logging\n", + "import os\n", + "\n", + "logger = logging.getLogger(__name__)\n", + "\n", + "\n", + "def mint() -> None:\n", + " logger.error(\"jwt_minter.no_secret\")\n", +); + +fn resolve_against_bundled_typeshed(source: &str) -> Arc { + let snapshot = Arc::new( + basilisk_stubs::typeshed::bundle::bundled_snapshot() + .expect("the release-attested bundled snapshot must activate"), + ); + let search_paths = typeshed_search_paths( + ActiveTypeshed::new( + snapshot, + Some(StubTarget { + python_version: (3, 12), + platform: StubTargetPlatform::Concrete("linux".to_owned()), + }), + ), + Vec::new(), + ); + cross_resolve(source, search_paths).expect("the fixture must parse and resolve") +} + +fn hover_markdown( + resolved: &basilisk_resolver::ResolvedModule, + source: &str, + offset: usize, +) -> Option { + match hover_at(resolved, source, offset, &[])?.contents { + HoverContents::Markup(markup) => Some(markup.value), + _ => panic!("hover contents must be Markdown"), + } +} + +/// The reported bug: hovering the `error` member of a `Logger` receiver +/// answered from typeshed's unrelated `os.error = OSError` binding, because +/// `identifier_at_offset` discards the `logger.` receiver and the bare-name +/// `imported_symbols` lookup pre-empted the dot-aware member lookup. +/// +/// A member hover must never be answered by a bare module-level name. +#[test] +fn hover_on_member_never_answers_from_an_unrelated_bare_module_name() { + let resolved = resolve_against_bundled_typeshed(LOGGER_SOURCE); + assert!( + resolved.imported_symbols.contains_key("error"), + "precondition: a plain `import os` publishes typeshed's `error = OSError` \ + under the bare key, which is what hover used to answer with" + ); + + let offset = LOGGER_SOURCE + .rfind("error") + .expect("the member access must be present") + + 1; + let markdown = hover_markdown(&resolved, LOGGER_SOURCE, offset); + + assert!( + !markdown.as_deref().is_some_and(|md| md.contains("OSError")), + "hovering `logger.error` must not answer with `os.error`: {markdown:?}" + ); +} + +/// [TYPESHEDRT-ACCEPTANCE-HOVER]: the receiver of a member access is typed +/// from the declaration that produced it — `logging.getLogger(...)` returns +/// `Logger` — so the hover shows `Logger.error`'s real typeshed signature, +/// labelled as a method and attributed to the active snapshot. +#[test] +fn hover_on_member_of_call_typed_receiver_shows_declaring_class_signature() { + let resolved = resolve_against_bundled_typeshed(LOGGER_SOURCE); + let offset = LOGGER_SOURCE + .rfind("error") + .expect("the member access must be present") + + 1; + let markdown = hover_markdown(&resolved, LOGGER_SOURCE, offset) + .expect("a member of a typed receiver must have hover"); + + assert!( + markdown.contains("Logger.error"), + "hover must qualify the member with the class that declares it: {markdown}" + ); + assert!( + markdown.contains("(method)"), + "hover must say the symbol is a method: {markdown}" + ); + assert!( + markdown.contains("msg"), + "hover must show the member's real parameters: {markdown}" + ); + assert!( + markdown.contains("(typeshed)"), + "hover must attribute the declaration to the active snapshot: {markdown}" + ); +} + +/// [LSPARCH-FEATURES-HOVER]: an imported symbol carries the same kind label a +/// local one does. `ExternalSymbol::kind` is known at hover time and was only +/// ever consulted to branch on classes, so imported functions rendered as a +/// bare signature with nothing saying what they are. +#[test] +fn hover_on_imported_stub_function_states_its_kind_and_origin() { + let source = "from logging import getLogger\n\nlog = getLogger(__name__)\n"; + let resolved = resolve_against_bundled_typeshed(source); + let offset = source + .rfind("getLogger") + .expect("the usage must be present") + + 1; + let markdown = + hover_markdown(&resolved, source, offset).expect("an imported symbol must have hover"); + + assert!( + markdown.contains("(function)"), + "an imported function must be labelled like a local one: {markdown}" + ); + assert!( + markdown.contains("def getLogger("), + "the stub signature must still be shown: {markdown}" + ); + assert!( + markdown.contains("logging"), + "hover must name the module the symbol came from: {markdown}" + ); +} diff --git a/crates/basilisk-lsp/tests/lsp/ws_test_capabilities.rs b/crates/basilisk-lsp/tests/lsp/ws_test_capabilities.rs index ee096740..b8662143 100644 --- a/crates/basilisk-lsp/tests/lsp/ws_test_capabilities.rs +++ b/crates/basilisk-lsp/tests/lsp/ws_test_capabilities.rs @@ -171,14 +171,20 @@ async fn test_ws_initialize_advertises_typeshed_status() -> TestResult<()> { .get("status") .ok_or("missing typed Typeshed state in initialize response")?; + // [LSPCFGED-TYPESHED]: resolution is a local read completed during + // `initialize`, so the payload carries the TERMINAL generation — there is + // no acquiring state for any client to render as a blocking overlay. assert_eq!( status .pointer("/lifecycle/kind") .and_then(serde_json::Value::as_str), - Some("Acquiring"), - "initialize must expose the pre-analysis Acquiring generation: {status}" + Some("Ready"), + "initialize must expose the terminal resolved generation: {status}" + ); + assert!( + status.get("activeSource").is_some(), + "a Ready status names its active source: {status}" ); - assert!(status.get("activeSource").is_none()); assert!(status .get("warnings") .is_some_and(serde_json::Value::is_array)); diff --git a/crates/basilisk-lsp/tests/lsp/ws_test_common.rs b/crates/basilisk-lsp/tests/lsp/ws_test_common.rs index 7079bcd7..17b29db4 100644 --- a/crates/basilisk-lsp/tests/lsp/ws_test_common.rs +++ b/crates/basilisk-lsp/tests/lsp/ws_test_common.rs @@ -153,6 +153,19 @@ python-version = \"3.12\"\n\ let response = self.recv().await.ok_or("no response to initialize")?; + // Typeshed resolution is a local read completed during `initialize` + // ([STUBRES-TYPESHED-OFFLINE]): the response payload itself carries + // each root's terminal status, so there is no startup notification to + // await — and nothing intermediate a client could ever render. + if !response.contains("typeshedStatuses") + || !response.contains("\"lifecycle\":{\"kind\":\"Ready\"}") + { + return Err(format!( + "initialize payload must carry a terminal Ready Typeshed status: {response}" + ) + .into()); + } + self.send_json(&serde_json::json!({ "jsonrpc": "2.0", "method": "initialized", @@ -160,21 +173,7 @@ python-version = \"3.12\"\n\ })) .await?; - // `initialized` acquires and gates Typeshed before analysis. Await the - // typed Ready notification so subsequent document opens can never - // observe the deliberately empty blocked-root publication. - for _ in 0..20 { - let message = self - .recv() - .await - .ok_or("no terminal Typeshed status after initialized")?; - if message.contains("\"method\":\"basilisk/typeshedStatusChanged\"") - && message.contains("\"lifecycle\":{\"kind\":\"Ready\"}") - { - return Ok(response); - } - } - Err("Typeshed did not become ready during initialization".into()) + Ok(response) } /// Send `textDocument/didOpen`. diff --git a/crates/basilisk-lsp/tests/lsp/ws_test_execute_stubs.rs b/crates/basilisk-lsp/tests/lsp/ws_test_execute_stubs.rs index 295f2604..f788bf98 100644 --- a/crates/basilisk-lsp/tests/lsp/ws_test_execute_stubs.rs +++ b/crates/basilisk-lsp/tests/lsp/ws_test_execute_stubs.rs @@ -1,4 +1,4 @@ -//! Tests for [STUBRES-CREATE-LOCAL]. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#stubres-create-local +//! Tests for [STUBRES-CREATE-LOCAL]. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CREATE-LOCAL // E2E test for the `basilisk.stubs.createLocal` quick fix (BSK-0152). // // Drives the full pipeline through `workspace/executeCommand`: diff --git a/crates/basilisk-lsp/tests/profiler_tests.rs b/crates/basilisk-lsp/tests/profiler_tests.rs index e9a629fe..48d47505 100644 --- a/crates/basilisk-lsp/tests/profiler_tests.rs +++ b/crates/basilisk-lsp/tests/profiler_tests.rs @@ -267,6 +267,95 @@ fn scaffolding_frames_are_stripped_from_every_surface() { assert_eq!(hot.self_samples, 1, "the user leaf keeps its self sample"); } +/// A debugpy housekeeping thread, leaf-first: debugpy's own code sitting on +/// the stdlib `threading.py` bootstrap spine that started it. Nothing here is +/// the user's. +fn debugger_housekeeping_thread_stack() -> Vec<(&'static str, &'static str, i32)> { + vec![ + ("_on_run", "/site-packages/debugpy/adapter/servers.py", 191), + ("run", "/usr/lib/python3.14/threading.py", 1012), + ("_bootstrap_inner", "/usr/lib/python3.14/threading.py", 1075), + ("_bootstrap", "/usr/lib/python3.14/threading.py", 1032), + ] +} + +/// The user's OWN worker thread, leaf-first: real user code on top of the same +/// stdlib bootstrap spine. +fn user_worker_thread_stack() -> Vec<(&'static str, &'static str, i32)> { + vec![ + ("crunch", "/home/user/proj/cpu_hotspot.py", 52), + ("worker", "/home/user/proj/cpu_hotspot.py", 60), + ("run", "/usr/lib/python3.14/threading.py", 1012), + ("_bootstrap_inner", "/usr/lib/python3.14/threading.py", 1075), + ("_bootstrap", "/usr/lib/python3.14/threading.py", 1032), + ] +} + +// Exercises [PROFILE-AGGREGATION-SCAFFOLD]. The spec requires that "a +// machinery-only thread (debugger housekeeping, the injected sampler) is +// dropped entirely". A debugpy housekeeping thread is started by the stdlib, +// so once its debugpy frames are stripped what remains is the `threading.py` +// bootstrap spine — non-empty, so the thread survived and surfaced as a +// top-level row in the .cpuprofile flame chart, which is exactly the launcher +// scaffolding this section exists to remove. +#[test] +fn a_debugger_housekeeping_thread_is_dropped_entirely() { + let mut data = ProfileData::default(); + data.ingest_traces( + &[make_trace(7, true, debugger_housekeeping_thread_stack())], + 0.01, + false, + ); + + assert!( + !data.thread_stacks.contains_key(&7), + "a thread whose only surviving frames are the stdlib bootstrap spine \ + is pure machinery and must not register, got: {:?}", + data.thread_stacks.get(&7) + ); + assert!( + data.frames.is_empty(), + "no machinery frame may reach the export, got: {:?}", + data.frames + ); +} + +// Exercises [PROFILE-AGGREGATION-SCAFFOLD] — the other half of the contract: +// dropping bootstrap frames must NOT drop the user's own worker threads. The +// spine is stripped so the thread roots at the user's code, and the user's +// frames and attribution survive intact. +#[test] +fn a_user_worker_thread_survives_and_roots_at_user_code() { + let mut data = ProfileData::default(); + data.ingest_traces( + &[make_trace(9, true, user_worker_thread_stack())], + 0.01, + false, + ); + + let stacks = data + .thread_stacks + .get(&9) + .expect("the user's worker thread must be retained"); + let root_frame = &data.frames[stacks[0][0]]; + assert_eq!( + root_frame.file, "/home/user/proj/cpu_hotspot.py", + "the worker thread must root at the user's file, not the bootstrap spine" + ); + assert_eq!( + root_frame.name, "worker", + "the worker thread must root at the user's own entry function" + ); + assert_eq!(stacks[0].len(), 2, "only the two user frames survive"); + + let hot = data + .function_stats + .get("/home/user/proj/cpu_hotspot.py") + .and_then(|by_fn| by_fn.get("crunch")) + .expect("crunch must keep its stats"); + assert_eq!(hot.self_samples, 1, "the user leaf keeps its self sample"); +} + #[test] fn leaf_tracer_frames_attribute_to_the_traced_user_line() { // Under debugpy line-tracing, pydevd's trace_dispatch can own the leaf; diff --git a/crates/basilisk-lsp/tests/typeshed_declaration_consumers_tests.rs b/crates/basilisk-lsp/tests/typeshed_declaration_consumers_tests.rs index 277b1a98..55673662 100644 --- a/crates/basilisk-lsp/tests/typeshed_declaration_consumers_tests.rs +++ b/crates/basilisk-lsp/tests/typeshed_declaration_consumers_tests.rs @@ -145,13 +145,9 @@ fn custom_builtins_mutation_replaces_every_consumer_without_bundle_mixing() { selection: basilisk_stubs::typeshed::source::SourceSelection::Custom { path: root.path().to_string_lossy().into_owned(), }, - verify_content: true, - use_cache: false, - url_template: None, + store_path: None, }; - let manager = basilisk_stubs::typeshed::runtime::production_manager(request, None); - assert!(manager.is_ok(), "custom manager must initialize"); - let Ok(manager) = manager else { return }; + let manager = basilisk_stubs::typeshed::runtime::production_manager(request); let snapshot = manager.snapshot(); assert!( snapshot.is_ok(), diff --git a/crates/basilisk-mojo/README.md b/crates/basilisk-mojo/README.md index 503e28c5..82900d31 100644 --- a/crates/basilisk-mojo/README.md +++ b/crates/basilisk-mojo/README.md @@ -4,26 +4,73 @@ Mojo-inspired ownership and immutability analysis for Basilisk. ## Role in Basilisk -This crate implements **static ownership semantics** as type annotations over standard Python syntax. Using `Annotated` from the `typing` module, developers can declare ownership contracts that Basilisk enforces at analysis time — catching mutation of borrowed values and use-after-move before they reach production. +This crate is **scaffolding** for static ownership semantics expressed as type +annotations over standard Python syntax. The design goal is to let developers +declare ownership contracts — catching mutation of borrowed values and +use-after-move — using only `typing` constructs, with no compiler or runtime +support required. -## Key concepts +Per [CHKARCH-MOJO-SAFETY](../../docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-MOJO-SAFETY), +this analysis is "planned, opt-in, and not wired into the checker pipeline". +Nothing here enforces anything today: the crate's only public entry point, +`check_ownership`, has no caller outside `tests/mojo_tests.rs`. -- **`Borrowed`** — a read-only reference. Mutation is a type error. -- **`InOut`** — a mutable reference. Must be explicitly declared. -- **`Owned`** — ownership is transferred. Use after transfer is a type error. -- **Standard Python syntax** — uses `typing.Annotated`, no compiler or runtime required. +## Key concepts (design vocabulary) + +- **`Borrowed`** — a read-only reference; mutation is intended to be an error. +- **`InOut`** — a mutable reference, explicitly declared. +- **`Owned`** — ownership is transferred; use after transfer is intended to be an error. +- **Standard Python syntax** — the target form is `Annotated[T, Borrowed|InOut|Owned]` + ([CHKARCH-MOJO-OWNERSHIP](../../docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-MOJO-OWNERSHIP)), + so no compiler or runtime is required. + +Of these, only `Borrowed` is recognised by the current prototype, and only in +the bare subscript/name form shown below — not yet in the `Annotated[...]` form +that the spec targets. + +## What the prototype actually detects + +`check_ownership(source: &str) -> Vec` is a line-oriented text scan, not +an AST pass (`src/lib.rs`): + +1. For each line starting with `def `, it splits the parenthesised parameter list + on `,` and collects the names of parameters whose annotation either starts + with `Borrowed[` or is exactly `Borrowed`. +2. It then flags any line containing `.(` for `` in + `MUTATING_METHODS` (`append`, `extend`, `insert`, `remove`, `pop`, `clear`, + `sort`, `reverse`, `update`, `add`, `discard`). + +Because step 1 splits on `,`, an annotation written as +`Annotated[list[int], Borrowed]` is broken into two fragments and matches +neither accepted shape, so it is **not** detected. ## Example ```python -from typing import Annotated -from basilisk import Borrowed - -def summarise(items: Annotated[list[int], Borrowed]) -> int: - items.append(99) # generics_defaults: mutation of Borrowed parameter +def summarise(items: Borrowed[list[int]]) -> int: + items.append(99) # detected: mutation of a Borrowed parameter return sum(items) ``` +`check_ownership` returns strings, not structured diagnostics. For the source +above it yields: + +``` +directives_cast: mutation of Borrowed parameter `items` via `.append()` is not allowed +``` + +**Known defect:** that `directives_cast:` prefix is not a diagnostic code for +this crate. `directives_cast` is a real, registered, shipping PEP rule meaning +"Invalid `cast()` call" (`crates/basilisk-checker/src/rules/directives_cast.rs`, +documented at ). +Reusing it here contradicts [CHKARCH-MOJO-SAFETY], which states that shipping +PEP rules and this scaffolding "must not reuse these anchors or diagnostic +descriptions". The string is hard-coded in `src/lib.rs` and needs its own code +before any of this is wired up. + ## Status -Phase 4 — ownership annotations designed, implementation in progress. +Planned. The crate is scaffolding targeted at Phase 4 (`src/lib.rs`); it is not +registered with the checker and produces no user-visible diagnostics. Remaining +work is tracked as unchecked TODOs under +[CHKADVPLAN-TODO-MOJO](../../docs/plans/CHECKER-ADVANCED-FEATURES-PLAN.md#CHKADVPLAN-TODO-MOJO). diff --git a/crates/basilisk-resolver/src/scope/external_symbol.rs b/crates/basilisk-resolver/src/scope/external_symbol.rs index a7db3450..0932f8c0 100644 --- a/crates/basilisk-resolver/src/scope/external_symbol.rs +++ b/crates/basilisk-resolver/src/scope/external_symbol.rs @@ -36,6 +36,13 @@ pub struct ExternalMethod { pub name: String, /// The rendered `def` signature for hover display. pub signature: String, + /// The method's docstring, when the defining module has one. + /// + /// `.pyi` stubs (typeshed included) carry no docstrings, so this is `None` + /// for them; workspace and PEP 561 `py.typed` sources do carry them, and + /// hover shows the prose it finds rather than dropping it + /// ([LSPARCH-FEATURES-HOVER]). + pub docstring: Option, } /// One class declaration indexed from the active standard-library snapshot. @@ -76,6 +83,13 @@ pub struct ExternalSymbol { pub source_span: Span, /// The full function/class signature for hover display. pub signature: Option, + /// The symbol's docstring, when the defining module has one. + /// + /// `.pyi` stubs (typeshed included) carry no docstrings, so this is `None` + /// for them; workspace and PEP 561 `py.typed` sources do carry them, and + /// hover shows the prose it finds rather than dropping it + /// ([LSPARCH-FEATURES-HOVER]). + pub docstring: Option, /// Where this symbol's type information came from. /// /// Set during cross-module resolution based on how the import was resolved. diff --git a/crates/basilisk-stubs/Cargo.toml b/crates/basilisk-stubs/Cargo.toml index 38a87ccc..7766eeff 100644 --- a/crates/basilisk-stubs/Cargo.toml +++ b/crates/basilisk-stubs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "basilisk-stubs" -description = "Runtime python/typeshed archive acquisition, cache, and bundled snapshot for Basilisk" +description = "Offline python/typeshed source resolution (bundle, store, custom) for Basilisk" version.workspace = true edition.workspace = true license.workspace = true @@ -17,21 +17,21 @@ ruff_python_ast.workspace = true ruff_text_size.workspace = true serde.workspace = true serde_json.workspace = true -# Content attestation for the typeshed acquisition core ([STUBRES-TYPESHED-ACQUIRE]): -# `sha1` reconstructs Git blob/tree object IDs to bind analysed bytes to the -# trusted root-tree SHA; `sha2` fingerprints the cached immutable ZIP. Both are -# pure-Rust RustCrypto crates with no system dependencies (no Dockerfile/CI change). +# Offline pin verification ([STUBRES-TYPESHED-PIN]): `sha1` reconstructs Git +# commit/blob/tree object IDs to bind analysed bytes to the pinned commit SHA; +# `sha2` fingerprints the embedded bundle and legal files. Both are pure-Rust +# RustCrypto crates with no system dependencies (no Dockerfile/CI change). sha1 = "0.10" sha2 = "0.10" -# Decode the bundled stdlib ZIP and the downloaded GitHub zipball into the -# in-memory archive model ([STUBRES-TYPESHED-ACQUIRE]). Deflate read only — no -# bzip2/zstd/aes surface, and never a `git` transport. +# Decode the bundled stdlib ZIP into the in-memory archive model +# ([STUBRES-TYPESHED-BASELINE]). Deflate read only — no bzip2/zstd/aes surface. zip = { version = "5.1.1", default-features = false, features = ["deflate"] } thiserror.workspace = true tracing.workspace = true -# Production authenticated-HTTPS adapter for GitHub metadata/codeload and -# configured archive mirrors. `https_only` is also enforced by the adapter. -ureq = { version = "3.3.0", default-features = false, features = ["rustls"] } +# NO HTTP client may ever appear here: the checker links this crate, and "the +# checker never downloads" is a property of the build ([TYPESHEDRT-SEGREGATION], +# enforced by scripts/check-dependency-shape.sh). Downloading lives in +# `basilisk-typeshed-fetch`. [dev-dependencies] serde_json.workspace = true diff --git a/crates/basilisk-stubs/README.md b/crates/basilisk-stubs/README.md index 71729fd8..0df9af88 100644 --- a/crates/basilisk-stubs/README.md +++ b/crates/basilisk-stubs/README.md @@ -1,38 +1,40 @@ # basilisk-stubs -Standard-library type resolution for Basilisk: a custom or downloaded -`python/typeshed` tree, with a bundled full-`stdlib/` ZIP snapshot as the offline -floor. +Standard-library type resolution for Basilisk: a custom `python/typeshed` +tree, or a pinned commit verified offline against the on-disk store, with a +bundled full-`stdlib/` ZIP snapshot as the default pin. ## Role in Basilisk This crate supplies step 3—"Typeshed stubs for the standard library"—of the pinned typing resolution order ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). -It returns a `StubResolution` with source and trust provenance; the normative -selection contract is +It returns a `StubResolution` whose active source IS the trust story; the +normative selection contract is [STUBRES-TYPESHED](../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED). ## Key concepts -- **Runtime typeshed acquisition** — an explicit `typeshed-commit` or the latest - verified `main` supplies real `.pyi` bodies, `stdlib/VERSIONS`, and the - distribution map from one SHA. The source archive is **downloaded over HTTPS - (never `git clone`)**, streamed through safety/shape/license/tree gates, cached - as an immutable ZIP that is re-hashed on every reuse, and read through the same - archive VFS. Cached bytes standing in for the moving `main` reference expire - after 24 hours; bytes for an explicitly pinned commit do not, because that - commit is content-addressed and every reuse re-hashes the ZIP against its - recorded SHA-256. An unpinned failed acquisition falls back to the bundled - snapshot and never reuses an older commit - ([STUBRES-TYPESHED-ACQUIRE](../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE)). +- **Offline pin verification** — an explicit `typeshed-commit` (or the bundled + commit when unset) supplies real `.pyi` bodies, `stdlib/VERSIONS`, and the + distribution map from one SHA. Resolution **never downloads**: it re-hashes + the store entry's materialized tree against the pin's commit object, offline, + on every activation. A pin that is not on this machine is a terminal + `NO SOURCE` failure naming `basilisk typeshed download` — never a fallback + ([STUBRES-TYPESHED-OFFLINE](../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-OFFLINE), + [STUBRES-TYPESHED-PIN](../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-PIN)). + Downloading lives solely in `basilisk-typeshed-fetch`, behind explicit user + actions, and writes immutable entries into the content-addressed store this + crate reads + ([STUBRES-TYPESHED-STORE](../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-STORE), + [STUBRES-TYPESHED-DOWNLOAD](../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-DOWNLOAD)). - **Bundled ZIP snapshot** — a complete typeshed `stdlib/` tree with **real `.pyi` bodies** plus its composite `LICENSE`, pinned to one SHA and refreshed per release. It is the offline floor, so #288/#289 hovers work with no network ([STUBRES-TYPESHED-BASELINE](../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-BASELINE), [STUBRES-TYPESHED-WARN](../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN)). -- **No mixed source** — custom or downloaded content wholly bypasses the bundled - snapshot. +- **No mixed source** — custom or store-backed pinned content wholly bypasses + the bundled snapshot. - **Custom typeshed** — `typeshed-path` is the sole step-3 source when set, as Basilisk's implementation of the pinned "canonical source" SHOULD; a miss proceeds to step 4 ([STUBRES-CUSTOM-TYPESHED](../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CUSTOM-TYPESHED)). @@ -50,10 +52,10 @@ selection contract is ## Status -The `typeshed-path` custom-tree override, runtime `python/typeshed` archive -acquisition, and bundled full-`stdlib/` ZIP snapshot all produce the sole active -step-3 snapshot consumed by `basilisk-checker`. Its real `.pyi` bodies and -derived indexes remain one indivisible source, as defined by +The `typeshed-path` custom-tree override, the offline store-backed pin, and +the bundled full-`stdlib/` ZIP snapshot all produce the sole active step-3 +snapshot consumed by `basilisk-checker`. Its real `.pyi` bodies and derived +indexes remain one indivisible source, as defined by [STUBRES-TYPESHED](../../docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED); -the HTTPS archive download (never `git clone`), tree-SHA verification, and -source reporting are tracked against that spec. +offline commit-object re-hashing, store immutability, and source reporting are +tracked against that spec. diff --git a/crates/basilisk-stubs/src/typeshed/archive.rs b/crates/basilisk-stubs/src/typeshed/archive.rs index 25cc1768..1322c4e3 100644 --- a/crates/basilisk-stubs/src/typeshed/archive.rs +++ b/crates/basilisk-stubs/src/typeshed/archive.rs @@ -1,4 +1,4 @@ -//! Implements [STUBRES-TYPESHED-ACQUIRE] archive model + VFS. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE +//! Implements [STUBRES-TYPESHED] archive model + VFS. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED //! //! In-memory archive model and archive VFS. //! @@ -11,7 +11,7 @@ //! [`ArchiveVfs`] is the read side the resolver consumes. It returns a **stable //! logical URI** plus bytes for `parse_pyi_source` ([STUBRES-PYI]) — there is no //! temp extraction and no real filesystem path, so a cached immutable ZIP is -//! never trusted through a mutable extracted tree ([STUBRES-TYPESHED-ACQUIRE]). +//! never trusted through a mutable extracted tree ([STUBRES-TYPESHED-PIN]). use std::collections::HashMap; use std::sync::Arc; diff --git a/crates/basilisk-stubs/src/typeshed/bundle.rs b/crates/basilisk-stubs/src/typeshed/bundle.rs index edacd2ea..d7a7c134 100644 --- a/crates/basilisk-stubs/src/typeshed/bundle.rs +++ b/crates/basilisk-stubs/src/typeshed/bundle.rs @@ -6,9 +6,10 @@ //! and the composite `LICENSE`) is embedded with `include_bytes!`. Because the //! bundle is a **stdlib subset**, it cannot reconstruct the full-repository Git //! root tree, so it is verified by its **embedded ZIP SHA-256 plus the license -//! manifest**, never by tree reconstruction ([STUBRES-TYPESHED-BASELINE]). Its -//! provenance is [`Provenance::BundleVetted`], and it always reports `UNPINNED` -//! (a build-time pin is not a user pin) until the user pins that commit. +//! manifest**, never by tree reconstruction ([STUBRES-TYPESHED-BASELINE]). It +//! is the data an unset `typeshed-commit` resolves to; the selector reports +//! that default `UNPINNED` (a build-time pin is not a user pin) and an explicit +//! pin of the same commit as pinned ([STUBRES-TYPESHED-WARN]). use std::sync::OnceLock; @@ -23,10 +24,7 @@ use super::gate::{ }; use super::gittree::{Oid, OidParseError}; use super::snapshot::{Snapshot, SnapshotError}; -use super::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, StatusWarning, Transport, TypeshedStatus, -}; -use super::warning::{TypeshedWarning, UnpinnedKind}; +use super::source::{LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus}; /// The embedded bundle ZIP. static BUNDLE_ZIP: &[u8] = include_bytes!(concat!( @@ -176,17 +174,15 @@ fn build_bundled_snapshot() -> Result { let commit = Oid::from_hex(&manifest.source.commit_sha).map_err(BundleError::BadSha)?; let tree = Oid::from_hex(&manifest.source.tree_sha).map_err(BundleError::BadSha)?; let identity = SourceIdentity::Bundled { commit }; + // Warnings are set by the selector, which knows whether an explicit pin + // selected this bundle (suppressing UNPINNED) or the default did not. let status = TypeshedStatus { active_source: SourceKind::Bundled, commit: Some(commit), tree: Some(tree), - transport: Transport::EmbeddedZip, license_status: LicenseStatus::Approved, license_reference: Some(license_reference(&manifest.source.commit_sha)), - provenance: Provenance::BundleVetted, - signed_release: false, - // A build-time pin is not a user pin ([STUBRES-TYPESHED-WARN]). - warnings: StatusWarning::list(&[TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled)]), + warnings: Vec::new(), }; let vfs = ArchiveVfs::new(identity.uri_component(), archive); Snapshot::build(identity, status, vfs, Some(BUNDLE_DISTRIBUTIONS_TSV)) @@ -244,14 +240,14 @@ mod tests { }; // Identity + status honesty. assert_eq!(snapshot.status.active_source, SourceKind::Bundled); - assert_eq!(snapshot.status.provenance, Provenance::BundleVetted); assert_eq!(snapshot.status.license_status, LicenseStatus::Approved); - // Build-time pin is not a user pin: still UNPINNED. - assert!(snapshot - .status - .warnings - .iter() - .any(|warning| warning.code == "UNPINNED")); + // Warnings belong to the selector, which knows whether an explicit pin + // chose this bundle; the raw bundle carries none of its own. + assert!(snapshot.status.warnings.is_empty()); + assert_eq!( + snapshot.status.commit.map(|oid| oid.to_hex()).as_deref(), + Some(bundled_commit_sha()) + ); } #[test] diff --git a/crates/basilisk-stubs/src/typeshed/cache.rs b/crates/basilisk-stubs/src/typeshed/cache.rs deleted file mode 100644 index 0e72cc84..00000000 --- a/crates/basilisk-stubs/src/typeshed/cache.rs +++ /dev/null @@ -1,682 +0,0 @@ -//! Implements [STUBRES-TYPESHED-ACQUIRE] cache. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE -//! -//! The on-disk immutable-ZIP cache. -//! -//! A verified download is cached as the **immutable ZIP** it was accepted as, -//! beside a small metadata record. Reuse **re-hashes the cached ZIP** and -//! compares it to the recorded SHA-256, so on-disk mutation is detected without -//! extraction — the checker never trusts a mutable extracted tree -//! ([STUBRES-TYPESHED-ACQUIRE]). Bytes standing in for the *moving* `main` -//! reference expire after 24 hours; bytes for an explicitly pinned exact commit -//! do not, because that commit is content-addressed and re-hashed on every -//! reuse. See [`DiskCache::load_fresh`] and [`DiskCache::load_pinned`]. - -use std::fs::{self, File}; -use std::io::Write as _; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use serde::{Deserialize, Serialize}; - -use super::gate::manifest::sha256_hex; - -static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0); - -/// Maximum age of cached ZIP bytes reused for a **moving** reference. Commit -/// selection is independent: an explicit pin remains the same identity when its -/// bytes must be reacquired, and is exempt from this window entirely -/// ([`DiskCache::load_pinned`]). -pub const CACHE_MAX_AGE_SECONDS: u64 = 24 * 60 * 60; - -/// A cache key derived from a source identity's opaque URI component. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CacheKey(String); - -impl CacheKey { - /// Build a key from a source identity's URI component (a SHA or path digest). - #[must_use] - pub fn from_identity(uri_component: &str) -> Self { - // The component is already a SHA or digest; keep only path-safe chars. - let safe: String = uri_component - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' { - ch - } else { - '_' - } - }) - .collect(); - Self(safe) - } - - /// The on-disk subdirectory name. - #[must_use] - pub fn dir_name(&self) -> &str { - &self.0 - } -} - -/// The metadata stored beside a cached ZIP. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CacheRecord { - /// The commit SHA, when known. - pub commit: Option, - /// The verified root-tree SHA, when content was attested. - pub tree: Option, - /// SHA-256 of the cached ZIP bytes — the mutation check on every reuse. - pub zip_sha256: String, - /// Whether content verification ran when the entry was stored. - pub verified: bool, - /// Actual archive byte origin (`codeload` or `mirror`). This is persisted - /// so reuse cannot be relabelled by a later configuration change. - #[serde(default)] - pub transport: Option, - /// Unix timestamp when these downloaded bytes passed the activation gates. - /// Legacy records default to zero and are therefore never reused. - #[serde(default)] - pub acquired_at_unix_seconds: u64, - /// Trusted recursive Git file metadata retained so offline cache reuse can - /// restore modes that codeload ZIPs do not preserve. - #[serde(default)] - pub tree_files: Vec, -} - -/// One persisted trusted recursive-tree leaf. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CachedTreeFile { - /// Repository-relative path. - pub path: String, - /// Full Git blob object ID. - pub oid: String, - /// Canonical Git leaf mode (`100644`, `100755`, `120000`, or `160000`). - pub mode: String, -} - -/// A cached ZIP plus its metadata. -#[derive(Debug, Clone)] -pub struct CachedArchive { - /// The immutable ZIP bytes. - pub zip: Vec, - /// Its metadata record. - pub record: CacheRecord, -} - -/// A cache operation failure. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum CacheError { - /// A filesystem error. - #[error("cache io error: {0}")] - Io(String), - /// The metadata record could not be (de)serialized. - #[error("cache metadata error: {0}")] - Metadata(String), - /// The cached ZIP's hash did not match its record — on-disk mutation. - #[error("cached archive mutated: recorded {expected}, found {actual}")] - Mutation { - /// The recorded SHA-256. - expected: String, - /// The freshly computed SHA-256. - actual: String, - }, -} - -/// The on-disk immutable-ZIP cache rooted at a directory. -#[derive(Debug, Clone)] -pub struct DiskCache { - root: PathBuf, -} - -impl DiskCache { - /// Root the cache at `root`. - #[must_use] - pub fn new(root: impl Into) -> Self { - Self { root: root.into() } - } - - fn entry_dir(&self, key: &CacheKey) -> PathBuf { - self.root.join(key.dir_name()) - } - - /// Store `zip` and `record` atomically under `key`. The record's - /// `zip_sha256` MUST already match `zip`; callers set it from the same bytes. - /// - /// # Errors - /// - /// Returns [`CacheError`] on I/O or serialization failure. - pub fn store( - &self, - key: &CacheKey, - zip: &[u8], - record: &CacheRecord, - ) -> Result<(), CacheError> { - let dir = self.entry_dir(key); - let generations = dir.join("generations"); - fs::create_dir_all(&generations).map_err(|err| CacheError::Io(err.to_string()))?; - let actual = sha256_hex(zip); - if actual != record.zip_sha256 { - return Err(CacheError::Mutation { - expected: record.zip_sha256.clone(), - actual, - }); - } - let meta = serde_json::to_vec_pretty(record) - .map_err(|err| CacheError::Metadata(err.to_string()))?; - let generation = generations.join(&record.zip_sha256); - if generation.is_dir() { - if stored_generation_matches(&generation, record) { - return Ok(()); - } - quarantine_generation(&generations, &generation)?; - } - let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let staging = generations.join(format!(".stage-{}-{sequence}", std::process::id())); - fs::create_dir(&staging).map_err(|err| CacheError::Io(err.to_string()))?; - let staged = stage_generation(&staging, zip, &meta); - if let Err(error) = staged { - let _ = fs::remove_dir_all(&staging); - return Err(error); - } - match fs::rename(&staging, &generation) { - Ok(()) => sync_dir(&generations), - Err(_error) if generation.is_dir() => { - let _ = fs::remove_dir_all(&staging); - Ok(()) - } - Err(error) => { - let _ = fs::remove_dir_all(&staging); - Err(CacheError::Io(error.to_string())) - } - } - } - - /// Load the freshest cached encoding for `key`, re-hashing every ZIP before - /// reuse. Multiple encodings can exist for one immutable commit. - /// - /// Returns `Ok(None)` when the entry is absent. - /// - /// # Errors - /// - /// Returns [`CacheError::Mutation`] if the stored ZIP no longer matches its - /// recorded hash, or an I/O/metadata error otherwise. - pub fn load_fresh( - &self, - key: &CacheKey, - now_unix_seconds: u64, - ) -> Result, CacheError> { - self.load_accepted(key, |record| record_is_fresh(record, now_unix_seconds)) - } - - /// Load the newest cached encoding for an **explicitly pinned** commit, - /// ignoring [`CACHE_MAX_AGE_SECONDS`]. - /// - /// The age window bounds how long bytes for a *moving* reference may stand - /// in for it: `main` advances, so yesterday's archive would misrepresent - /// "latest". A pinned commit has no such failure mode. It is - /// content-addressed, so its archive can never change meaning, and every - /// generation is re-hashed against its recorded `zip_sha256` on load - /// regardless of age. Expiring one therefore buys no integrity — it only - /// forces a network round-trip that stops a pinned, reproducible project - /// from typechecking offline or while GitHub is rate-limiting - /// ([STUBRES-TYPESHED-CONFIG]). - /// - /// Returns `Ok(None)` when the entry is absent. - /// - /// # Errors - /// - /// Returns [`CacheError::Mutation`] if the stored ZIP no longer matches its - /// recorded hash, or an I/O/metadata error otherwise. - pub fn load_pinned(&self, key: &CacheKey) -> Result, CacheError> { - self.load_accepted(key, |_record| true) - } - - fn load_accepted( - &self, - key: &CacheKey, - accept: impl Fn(&CacheRecord) -> bool, - ) -> Result, CacheError> { - let dir = self.entry_dir(key); - let generations = dir.join("generations"); - if !generations.is_dir() { - return Ok(None); - } - let mut promoted = fs::read_dir(&generations) - .map_err(|err| CacheError::Io(err.to_string()))? - .collect::, _>>() - .map_err(|err| CacheError::Io(err.to_string()))?; - promoted.retain(|entry| { - entry.file_type().is_ok_and(|kind| kind.is_dir()) - && !entry.file_name().to_string_lossy().starts_with('.') - }); - let mut freshest: Option = None; - for entry in promoted { - let candidate = load_generation(&generations, &entry.path())?; - if !accept(&candidate.record) { - continue; - } - let replace = match &freshest { - None => true, - Some(current) => { - let candidate_key = ( - candidate.record.acquired_at_unix_seconds, - candidate.record.zip_sha256.as_str(), - ); - let current_key = ( - current.record.acquired_at_unix_seconds, - current.record.zip_sha256.as_str(), - ); - candidate_key > current_key - } - }; - if replace { - freshest = Some(candidate); - } - } - Ok(freshest) - } -} - -fn load_generation(generations: &Path, generation: &Path) -> Result { - let zip = - fs::read(generation.join("archive.zip")).map_err(|err| CacheError::Io(err.to_string()))?; - let meta = - fs::read(generation.join("meta.json")).map_err(|err| CacheError::Io(err.to_string()))?; - let record: CacheRecord = - serde_json::from_slice(&meta).map_err(|err| CacheError::Metadata(err.to_string()))?; - let actual = sha256_hex(&zip); - if actual != record.zip_sha256 { - quarantine_generation(generations, generation)?; - return Err(CacheError::Mutation { - expected: record.zip_sha256, - actual, - }); - } - Ok(CachedArchive { zip, record }) -} - -fn record_is_fresh(record: &CacheRecord, now_unix_seconds: u64) -> bool { - record.acquired_at_unix_seconds != 0 - && now_unix_seconds >= record.acquired_at_unix_seconds - && now_unix_seconds - record.acquired_at_unix_seconds < CACHE_MAX_AGE_SECONDS -} - -fn stored_generation_matches(generation: &Path, expected: &CacheRecord) -> bool { - let Ok(zip) = fs::read(generation.join("archive.zip")) else { - return false; - }; - let Ok(meta) = fs::read(generation.join("meta.json")) else { - return false; - }; - let Ok(record) = serde_json::from_slice::(&meta) else { - return false; - }; - record == *expected && sha256_hex(&zip) == expected.zip_sha256 -} - -fn quarantine_generation(generations: &Path, generation: &Path) -> Result<(), CacheError> { - let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let quarantine = generations.join(format!(".corrupt-{}-{sequence}", std::process::id())); - fs::rename(generation, quarantine).map_err(|err| CacheError::Io(err.to_string()))?; - sync_dir(generations) -} - -fn stage_generation(staging: &Path, zip: &[u8], meta: &[u8]) -> Result<(), CacheError> { - write_synced(&staging.join("archive.zip"), zip)?; - write_synced(&staging.join("meta.json"), meta)?; - sync_dir(staging) -} - -fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), CacheError> { - let mut file = File::create(path).map_err(|err| CacheError::Io(err.to_string()))?; - file.write_all(bytes) - .map_err(|err| CacheError::Io(err.to_string()))?; - file.sync_all() - .map_err(|err| CacheError::Io(err.to_string())) -} - -#[cfg(unix)] -fn sync_dir(path: &Path) -> Result<(), CacheError> { - File::open(path) - .and_then(|directory| directory.sync_all()) - .map_err(|err| CacheError::Io(err.to_string())) -} - -#[cfg(not(unix))] -fn sync_dir(_path: &Path) -> Result<(), CacheError> { - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - const NOW: u64 = 1_000_000; - - fn record(zip: &[u8]) -> CacheRecord { - CacheRecord { - commit: Some("83c2518a9e6abbda0c44592c3483de459198f887".to_owned()), - tree: None, - zip_sha256: sha256_hex(zip), - verified: true, - transport: Some("codeload".to_owned()), - acquired_at_unix_seconds: NOW, - tree_files: Vec::new(), - } - } - - #[test] - fn store_then_load_round_trips() { - let Ok(dir) = tempfile::tempdir() else { - return; - }; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("83c2518a9e6abbda0c44592c3483de459198f887"); - let zip = b"PK\x03\x04 fake zip bytes"; - assert!(cache.store(&key, zip, &record(zip)).is_ok()); - let loaded = cache.load_fresh(&key, NOW); - assert!(matches!(&loaded, Ok(Some(entry)) if entry.zip == zip)); - } - - #[test] - fn store_rejects_bytes_that_do_not_match_the_recorded_digest( - ) -> Result<(), Box> { - let dir = tempfile::tempdir()?; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("digest-mismatch"); - let expected = record(b"expected bytes"); - - assert!(matches!( - cache.store(&key, b"different bytes", &expected), - Err(CacheError::Mutation { .. }) - )); - assert!(matches!(cache.load_fresh(&key, NOW), Ok(None))); - Ok(()) - } - - #[test] - fn conflicting_metadata_for_one_encoding_is_quarantined_and_replaced( - ) -> Result<(), Box> { - let dir = tempfile::tempdir()?; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("metadata-conflict"); - let zip = b"one immutable encoding"; - let first = record(zip); - assert!(cache.store(&key, zip, &first).is_ok()); - - let mut replacement = first; - replacement.acquired_at_unix_seconds = NOW + 1; - assert!(cache.store(&key, zip, &replacement).is_ok()); - - let loaded = cache.load_fresh(&key, NOW + 1); - assert!(matches!( - loaded, - Ok(Some(entry)) if entry.record == replacement && entry.zip == zip - )); - let generations = dir.path().join("metadata-conflict").join("generations"); - let quarantined = fs::read_dir(generations)? - .filter_map(Result::ok) - .any(|entry| entry.file_name().to_string_lossy().starts_with(".corrupt-")); - assert!(quarantined); - Ok(()) - } - - #[test] - fn missing_entry_is_none() { - let Ok(dir) = tempfile::tempdir() else { - return; - }; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("deadbeef"); - assert!(matches!(cache.load_fresh(&key, NOW), Ok(None))); - } - - #[test] - fn mutation_is_detected_on_reuse() -> Result<(), Box> { - let dir = tempfile::tempdir()?; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("abc123"); - let zip = b"original bytes"; - assert!(cache.store(&key, zip, &record(zip)).is_ok()); - // Tamper with the stored ZIP on disk. - let tampered = dir - .path() - .join("abc123") - .join("generations") - .join(sha256_hex(zip)) - .join("archive.zip"); - fs::write(&tampered, b"tampered bytes")?; - assert!(matches!( - cache.load_fresh(&key, NOW), - Err(CacheError::Mutation { .. }) - )); - assert!(matches!(cache.load_fresh(&key, NOW), Ok(None))); - Ok(()) - } - - #[test] - fn interrupted_staging_directory_never_activates() { - let Ok(dir) = tempfile::tempdir() else { - return; - }; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("interrupted"); - let staging = dir - .path() - .join("interrupted") - .join("generations") - .join(".stage-interrupted"); - assert!(fs::create_dir_all(&staging).is_ok()); - assert!(fs::write(staging.join("archive.zip"), b"partial").is_ok()); - assert!(matches!(cache.load_fresh(&key, NOW), Ok(None))); - - let zip = b"complete generation"; - assert!(cache.store(&key, zip, &record(zip)).is_ok()); - assert!(matches!(cache.load_fresh(&key, NOW), Ok(Some(entry)) if entry.zip == zip)); - } - - #[test] - fn concurrent_promotions_expose_only_complete_generations() { - let Ok(dir) = tempfile::tempdir() else { - return; - }; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("concurrent"); - std::thread::scope(|scope| { - for zip in [b"encoding-a".as_slice(), b"encoding-b".as_slice()] { - let cache = cache.clone(); - let key = key.clone(); - let _ = scope.spawn(move || cache.store(&key, zip, &record(zip))); - } - }); - assert!(matches!(cache.load_fresh(&key, NOW), Ok(Some(_)))); - } - - #[test] - fn bytes_expire_at_twenty_four_hour_boundary() { - let Ok(dir) = tempfile::tempdir() else { - return; - }; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("expiry"); - let zip = b"immutable bytes"; - assert!(cache.store(&key, zip, &record(zip)).is_ok()); - assert!(matches!( - cache.load_fresh(&key, NOW + CACHE_MAX_AGE_SECONDS - 1), - Ok(Some(_)) - )); - assert!(matches!( - cache.load_fresh(&key, NOW + CACHE_MAX_AGE_SECONDS), - Ok(None) - )); - } - - /// The age window exists to stop stale bytes standing in for a reference - /// that moves. A pinned commit does not move, so the same bytes that - /// [`DiskCache::load_fresh`] has expired must still be reusable through - /// [`DiskCache::load_pinned`] — otherwise a pinned, reproducible project - /// cannot typecheck offline or while GitHub is rate-limiting. - #[test] - fn a_pinned_commit_reuses_bytes_that_have_aged_out_for_a_moving_reference() { - let Ok(dir) = tempfile::tempdir() else { - return; - }; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("pinned-expiry"); - let zip = b"immutable pinned bytes"; - assert!(cache.store(&key, zip, &record(zip)).is_ok()); - - let long_past_expiry = NOW + CACHE_MAX_AGE_SECONDS * 365; - assert!( - matches!(cache.load_fresh(&key, long_past_expiry), Ok(None)), - "a moving reference must not reuse year-old bytes" - ); - assert!( - matches!(cache.load_pinned(&key), Ok(Some(entry)) if entry.zip == zip), - "a content-addressed pin has no expiry" - ); - } - - /// Age-blindness must not become verification-blindness: the mutation check - /// is the whole reason expiry is safe to drop, so it still runs. - #[test] - fn a_pinned_load_still_rejects_a_mutated_archive() -> Result<(), Box> { - let dir = tempfile::tempdir()?; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("pinned-mutation"); - let zip = b"pinned original bytes"; - assert!(cache.store(&key, zip, &record(zip)).is_ok()); - fs::write( - dir.path() - .join("pinned-mutation") - .join("generations") - .join(sha256_hex(zip)) - .join("archive.zip"), - b"tampered pinned bytes", - )?; - - assert!(matches!( - cache.load_pinned(&key), - Err(CacheError::Mutation { .. }) - )); - Ok(()) - } - - #[test] - fn future_or_legacy_timestamp_is_not_reused() { - let Ok(dir) = tempfile::tempdir() else { - return; - }; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("clock"); - let zip = b"clock bytes"; - let mut future = record(zip); - future.acquired_at_unix_seconds = NOW + 1; - assert!(cache.store(&key, zip, &future).is_ok()); - assert!(matches!(cache.load_fresh(&key, NOW), Ok(None))); - - let legacy_key = CacheKey::from_identity("legacy"); - let mut legacy = record(zip); - legacy.acquired_at_unix_seconds = 0; - assert!(cache.store(&legacy_key, zip, &legacy).is_ok()); - assert!(matches!(cache.load_fresh(&legacy_key, NOW), Ok(None))); - } - - #[test] - fn newer_fresh_encoding_wins_over_an_expired_lower_digest() { - let Ok(dir) = tempfile::tempdir() else { - return; - }; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("same-commit"); - let first = b"first archive encoding"; - let second = b"second archive encoding"; - let (expired_zip, fresh_zip) = if sha256_hex(first) < sha256_hex(second) { - (first.as_slice(), second.as_slice()) - } else { - (second.as_slice(), first.as_slice()) - }; - let mut expired = record(expired_zip); - expired.acquired_at_unix_seconds = NOW - CACHE_MAX_AGE_SECONDS; - assert!(cache.store(&key, expired_zip, &expired).is_ok()); - assert!(cache.store(&key, fresh_zip, &record(fresh_zip)).is_ok()); - - let loaded = cache.load_fresh(&key, NOW); - assert!(matches!(loaded, Ok(Some(entry)) if entry.zip == fresh_zip)); - } - - #[test] - fn equal_age_encodings_resolve_deterministically_by_digest( - ) -> Result<(), Box> { - let dir = tempfile::tempdir()?; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("equal-age"); - let first = b"first equally fresh encoding"; - let second = b"second equally fresh encoding"; - assert!(cache.store(&key, first, &record(first)).is_ok()); - assert!(cache.store(&key, second, &record(second)).is_ok()); - - let expected = if sha256_hex(first) > sha256_hex(second) { - first.as_slice() - } else { - second.as_slice() - }; - assert!(matches!( - cache.load_fresh(&key, NOW), - Ok(Some(entry)) if entry.zip == expected - )); - Ok(()) - } - - #[test] - fn fresher_encoding_wins_even_when_its_digest_sorts_lower( - ) -> Result<(), Box> { - let dir = tempfile::tempdir()?; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("freshness-first"); - let first = b"first different encoding"; - let second = b"second different encoding"; - let (lower_digest, higher_digest) = if sha256_hex(first) < sha256_hex(second) { - (first.as_slice(), second.as_slice()) - } else { - (second.as_slice(), first.as_slice()) - }; - let mut older = record(higher_digest); - older.acquired_at_unix_seconds = NOW - 1; - let newer = record(lower_digest); - assert!(cache.store(&key, higher_digest, &older).is_ok()); - assert!(cache.store(&key, lower_digest, &newer).is_ok()); - - assert!(matches!( - cache.load_fresh(&key, NOW), - Ok(Some(entry)) if entry.zip == lower_digest - )); - Ok(()) - } - - #[test] - fn malformed_promoted_metadata_fails_closed() -> Result<(), Box> { - let dir = tempfile::tempdir()?; - let cache = DiskCache::new(dir.path()); - let key = CacheKey::from_identity("bad-metadata"); - let generation = dir - .path() - .join("bad-metadata") - .join("generations") - .join("generation"); - fs::create_dir_all(&generation)?; - fs::write(generation.join("archive.zip"), b"bytes")?; - fs::write(generation.join("meta.json"), b"not json")?; - - assert!(matches!( - cache.load_fresh(&key, NOW), - Err(CacheError::Metadata(_)) - )); - Ok(()) - } - - #[test] - fn key_sanitizes_unsafe_characters() { - let key = CacheKey::from_identity("custom-/etc/passwd"); - assert!(!key.dir_name().contains('/')); - } -} diff --git a/crates/basilisk-stubs/src/typeshed/codec.rs b/crates/basilisk-stubs/src/typeshed/codec.rs index 8b9e0a90..03887b31 100644 --- a/crates/basilisk-stubs/src/typeshed/codec.rs +++ b/crates/basilisk-stubs/src/typeshed/codec.rs @@ -1,4 +1,4 @@ -//! Implements [STUBRES-TYPESHED-ACQUIRE] ZIP decode. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE +//! Implements [STUBRES-TYPESHED-DOWNLOAD] ZIP decode. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-DOWNLOAD //! //! ZIP → [`Archive`] decoding with streaming zip-bomb caps. //! @@ -6,7 +6,7 @@ //! The decoder enforces entry-count, per-entry, **total**, and compression-ratio //! caps *while reading each entry*, so an inflated entry is bounded before it is //! fully allocated — a later gate over a fully-inflated archive would be too late -//! ([STUBRES-TYPESHED-ACQUIRE]). Encrypted entries are rejected outright. When a +//! ([STUBRES-TYPESHED-DOWNLOAD]). Encrypted entries are rejected outright. When a //! top-level prefix is stripped (GitHub zipballs nest under `typeshed-/`), //! the decoder requires a single coherent non-`..` common root, so a mixed-root //! or `../evil`-prefixed archive can never normalize into a clean path. Path diff --git a/crates/basilisk-stubs/src/typeshed/gate/errors.rs b/crates/basilisk-stubs/src/typeshed/gate/errors.rs index 3b312769..3a943225 100644 --- a/crates/basilisk-stubs/src/typeshed/gate/errors.rs +++ b/crates/basilisk-stubs/src/typeshed/gate/errors.rs @@ -1,4 +1,4 @@ -//! Implements [STUBRES-TYPESHED-ACQUIRE] gate errors. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE +//! Implements [STUBRES-TYPESHED-DOWNLOAD] gate errors. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-DOWNLOAD //! //! The rejection taxonomy for the four activation gates. diff --git a/crates/basilisk-stubs/src/typeshed/gate/manifest.rs b/crates/basilisk-stubs/src/typeshed/gate/manifest.rs index 117027ae..765229f2 100644 --- a/crates/basilisk-stubs/src/typeshed/gate/manifest.rs +++ b/crates/basilisk-stubs/src/typeshed/gate/manifest.rs @@ -61,8 +61,11 @@ impl LicenseManifest { /// Whether a path names a legal file (`LICENSE*`/`NOTICE*`, case-insensitive) /// within the **relevant scope** — the archive root or under `stdlib/`. This /// matches the bundle updater, so a full archive's unrelated `stubs/**` legal -/// files never register as drift. -fn is_legal_file(path: &str) -> bool { +/// files never register as drift. Public because the store writer and reader +/// share this exact rule for which paths a store entry materializes +/// ([STUBRES-TYPESHED-STORE]). +#[must_use] +pub fn is_legal_file(path: &str) -> bool { let relevant = !path.contains('/') || path.starts_with("stdlib/"); if !relevant { return false; diff --git a/crates/basilisk-stubs/src/typeshed/gate/mod.rs b/crates/basilisk-stubs/src/typeshed/gate/mod.rs index 3acce3a3..570d5a3f 100644 --- a/crates/basilisk-stubs/src/typeshed/gate/mod.rs +++ b/crates/basilisk-stubs/src/typeshed/gate/mod.rs @@ -1,4 +1,4 @@ -//! Implements [STUBRES-TYPESHED-ACQUIRE] activation gates. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE +//! Implements [STUBRES-TYPESHED-DOWNLOAD] activation gates. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-DOWNLOAD //! //! The four activation gates. //! @@ -11,8 +11,7 @@ //! 3. **License** — the discovered `LICENSE*`/`NOTICE*` path+SHA-256 set must //! exactly match a build-approved identity ([STUBRES-TYPESHED-LICENSE]). //! 4. **Content** — reconstruct the Git root tree and match the trusted -//! root-tree SHA. **Only this gate may be waived**; waiving it emits -//! [`TypeshedWarning::Unverified`] and never disables the first three. +//! root-tree SHA. No gate can be waived ([STUBRES-TYPESHED-PIN]). pub mod errors; pub mod manifest; @@ -24,7 +23,6 @@ use std::collections::HashSet; use super::archive::{Archive, ArchiveVfs}; use super::gittree::{FileMode, Oid}; -use super::warning::TypeshedWarning; /// Byte and count limits enforced by the Safety gate. #[derive(Debug, Clone, Copy)] @@ -225,26 +223,21 @@ pub struct GateConfig { pub approved_license: LicenseManifest, /// The trusted root-tree SHA to attest against. pub expected_root_tree: Oid, - /// Whether to run the Content gate; `false` waives only content attestation. - pub verify_content: bool, } -/// A successfully activated archive: its VFS, any status warnings, and the -/// verified tree SHA (absent when content verification was waived). +/// A successfully activated archive: its VFS and the verified tree SHA. #[derive(Debug, Clone)] pub struct Activation { /// The archive VFS the resolver reads through. pub vfs: ArchiveVfs, - /// Composable status warnings raised during activation. - pub warnings: Vec, - /// The verified root-tree SHA, or `None` when content verification was off. - pub root_tree: Option, + /// The verified root-tree SHA. + pub root_tree: Oid, } /// Run all four gates in order and produce an [`Activation`]. /// -/// Safety, Shape, and License always run; Content runs unless waived, in which -/// case [`TypeshedWarning::Unverified`] is raised. +/// Every gate always runs. There is no waiver: a pin you can switch off is a +/// pin that does nothing ([STUBRES-TYPESHED-PIN]). /// /// # Errors /// @@ -257,16 +250,10 @@ pub fn run_activation( safety_gate(&archive, &config.limits).map_err(GateError::Safety)?; shape_gate(&archive).map_err(GateError::Shape)?; license_gate(&archive, &config.approved_license).map_err(GateError::License)?; - let mut warnings = Vec::new(); - let root_tree = if config.verify_content { - Some(content_gate(&archive, &config.expected_root_tree).map_err(GateError::Content)?) - } else { - warnings.push(TypeshedWarning::Unverified); - None - }; + let root_tree = + content_gate(&archive, &config.expected_root_tree).map_err(GateError::Content)?; Ok(Activation { vfs: ArchiveVfs::new(identity, archive), - warnings, root_tree, }) } @@ -297,32 +284,35 @@ mod tests { ] } - fn config_for(archive: &Archive, verify: bool) -> GateConfig { + fn config_for(archive: &Archive) -> GateConfig { GateConfig { limits: SafetyLimits::default(), approved_license: LicenseManifest::discover(archive), expected_root_tree: archive.root_tree_oid().unwrap(), - verify_content: verify, } } #[test] fn valid_archive_activates_and_verifies() { let archive = Archive::new(valid_entries()); - let config = config_for(&archive, true); + let config = config_for(&archive); + let root = config.expected_root_tree; let activation = run_activation(archive, &config, "83c2518").unwrap(); - assert!(activation.warnings.is_empty()); - assert!(activation.root_tree.is_some()); + assert_eq!(activation.root_tree, root); assert_eq!(activation.vfs.identity(), "83c2518"); } + /// Content verification always runs — there is no waiver field, flag, or + /// mode; a mutated tree is terminal ([STUBRES-TYPESHED-PIN] no-waiver). #[test] - fn waived_content_emits_unverified_only() { + fn content_verification_cannot_be_waived() { let archive = Archive::new(valid_entries()); - let config = config_for(&archive, false); - let activation = run_activation(archive, &config, "83c2518").unwrap(); - assert_eq!(activation.warnings, vec![TypeshedWarning::Unverified]); - assert!(activation.root_tree.is_none()); + let mut config = config_for(&archive); + config.expected_root_tree = crate::typeshed::gittree::git_blob_oid(b"not the tree"); + assert!(matches!( + run_activation(archive, &config, "83c2518"), + Err(GateError::Content(ContentViolation::TreeMismatch { .. })) + )); } #[test] diff --git a/crates/basilisk-stubs/src/typeshed/gittree.rs b/crates/basilisk-stubs/src/typeshed/gittree.rs index 3e7a253b..12b50eb2 100644 --- a/crates/basilisk-stubs/src/typeshed/gittree.rs +++ b/crates/basilisk-stubs/src/typeshed/gittree.rs @@ -1,4 +1,4 @@ -//! Implements [STUBRES-TYPESHED-ACQUIRE] Content gate. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE +//! Implements [STUBRES-TYPESHED-PIN] content addressing. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-PIN //! //! Git object-ID reconstruction. //! @@ -175,6 +175,53 @@ pub fn git_blob_oid(content: &[u8]) -> Oid { git_object_oid(b"blob", content) } +/// Compute a Git **commit** object ID for raw commit-object content (the bytes +/// `git cat-file commit` prints, without the `commit {len}\0` framing). +/// +/// This is the offline anchor of a pin: the stored commit object must hash to +/// the pinned SHA before its tree SHA is trusted ([STUBRES-TYPESHED-PIN]). +#[must_use] +pub fn git_commit_oid(content: &[u8]) -> Oid { + git_object_oid(b"commit", content) +} + +/// Error reading the root-tree SHA out of a raw commit object. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CommitParseError { + /// The content did not begin with a `tree <40-hex>\n` header. + #[error("commit object does not begin with a tree header")] + MissingTreeHeader, + /// The tree header's SHA was not valid hex. + #[error("commit object tree header is not a valid object id: {0}")] + BadTreeSha(OidParseError), +} + +/// Read the root-tree SHA from raw commit-object content. +/// +/// Git guarantees the first header of a commit object is `tree ` +/// ([Git `commit-tree`](https://git-scm.com/docs/git-commit-tree)), so this +/// parses exactly that shape and nothing else — a malformed object fails +/// rather than being guessed at. +/// +/// # Errors +/// +/// Returns [`CommitParseError`] when the content does not start with a +/// well-formed tree header. +pub fn commit_root_tree(content: &[u8]) -> Result { + let rest = content + .strip_prefix(b"tree ") + .ok_or(CommitParseError::MissingTreeHeader)?; + let (sha_bytes, terminator) = rest + .split_at_checked(40) + .ok_or(CommitParseError::MissingTreeHeader)?; + if terminator.first() != Some(&b'\n') { + return Err(CommitParseError::MissingTreeHeader); + } + let sha = + std::str::from_utf8(sha_bytes).map_err(|_error| CommitParseError::MissingTreeHeader)?; + Oid::from_hex(sha).map_err(CommitParseError::BadTreeSha) +} + /// Reconstruct the Git **root-tree** object ID for a flat set of archived files. /// /// An empty set yields Git's canonical empty-tree ID. Reconstruction is exact @@ -430,4 +477,41 @@ mod tests { Err(TreeError::DuplicatePath(_)) )); } + + /// Pinned Git constant: `printf 'tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor a 0 +0000\ncommitter a 0 +0000\n\nx\n' | git hash-object -t commit --stdin`. + #[test] + fn commit_oid_matches_git() { + let raw = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor a 0 +0000\ncommitter a 0 +0000\n\nx\n"; + assert_eq!( + git_commit_oid(raw).to_hex(), + "1f575658d4ebb47930907abae76eec9e594fa80d" + ); + } + + #[test] + fn commit_root_tree_reads_exactly_the_leading_tree_header() { + let raw = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor a 0 +0000\n\nmsg\n"; + assert_eq!( + commit_root_tree(raw).map(|oid| oid.to_hex()), + Ok(EMPTY_TREE.to_owned()) + ); + // A parent header first, a truncated SHA, or non-hex all fail closed. + assert_eq!( + commit_root_tree(b"parent 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n"), + Err(CommitParseError::MissingTreeHeader) + ); + assert_eq!( + commit_root_tree(b"tree 4b825dc6\n"), + Err(CommitParseError::MissingTreeHeader) + ); + assert!(matches!( + commit_root_tree(b"tree zz825dc642cb6eb9a060e54bf8d69288fbee4904\n"), + Err(CommitParseError::BadTreeSha(_)) + )); + assert_eq!( + commit_root_tree(b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904"), + Err(CommitParseError::MissingTreeHeader), + "a tree header without its newline terminator is malformed" + ); + } } diff --git a/crates/basilisk-stubs/src/typeshed/manager.rs b/crates/basilisk-stubs/src/typeshed/manager.rs index d67b7ca1..5e3db9de 100644 --- a/crates/basilisk-stubs/src/typeshed/manager.rs +++ b/crates/basilisk-stubs/src/typeshed/manager.rs @@ -1,21 +1,22 @@ -//! Implements [STUBRES-TYPESHED] one-generation acquisition. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED +//! Implements [STUBRES-TYPESHED] one-generation resolution. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED //! //! A manager is constructed for one effective, config-free [`TypeshedRequest`]. //! Its first caller performs selection; concurrent CLI/LSP/MCP consumers block //! on the same [`OnceLock`] and receive the same [`Arc`]. Success and -//! failure are both memoized for the run/session, so `main` is never resolved -//! twice and no caller can observe a partially promoted generation. +//! failure are both memoized for the run/session, so no caller can observe a +//! partially promoted generation. Selection is a local read +//! ([STUBRES-TYPESHED-OFFLINE]) — fast, and never a network wait. use std::sync::{Arc, OnceLock}; -use super::selector::{select_snapshot, AcquisitionBackend, SelectionError}; +use super::selector::{select_snapshot, SelectionError, SourceBackend}; use super::snapshot::Snapshot; use super::source::{TypeshedRequest, TypeshedStatus}; /// One config generation's single-flight typeshed manager. pub struct TypeshedManager { request: TypeshedRequest, - backend: Arc, + backend: Arc, selected: OnceLock, SelectionError>>, } @@ -32,7 +33,7 @@ impl std::fmt::Debug for TypeshedManager { impl TypeshedManager { /// Create a manager for one resolved configuration generation. #[must_use] - pub fn new(request: TypeshedRequest, backend: Arc) -> Self { + pub fn new(request: TypeshedRequest, backend: Arc) -> Self { Self { request, backend, @@ -40,19 +41,19 @@ impl TypeshedManager { } } - /// The immutable request this manager will acquire exactly once. + /// The immutable request this manager will resolve exactly once. #[must_use] pub const fn request(&self) -> &TypeshedRequest { &self.request } - /// Acquire or reuse the one complete active snapshot. + /// Resolve or reuse the one complete active snapshot. /// /// # Errors /// /// Returns a redacted [`SelectionError`]. The same failure is returned to /// every caller for this manager; callers never retry into a different - /// generation or expose adapter URLs/credentials. + /// generation and never receive a substitute source. pub fn snapshot(&self) -> Result, SelectionError> { self.selected .get_or_init(|| select_snapshot(&self.request, self.backend.as_ref()).map(Arc::new)) @@ -71,7 +72,7 @@ impl TypeshedManager { self.snapshot().map(|snapshot| snapshot.status.clone()) } - /// The ready snapshot without starting acquisition, if selection already + /// The ready snapshot without starting resolution, if selection already /// completed successfully. #[must_use] pub fn ready_snapshot(&self) -> Option> { @@ -96,15 +97,15 @@ mod tests { use super::super::gittree::{FileMode, Oid}; use super::super::selector::BackendError; use super::super::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, SourceSelection, Transport, - TypeshedStatus, + LicenseStatus, SourceIdentity, SourceKind, SourceSelection, TypeshedStatus, }; use super::*; - const LATEST_SHA: &str = "0123456789012345678901234567890123456789"; + const PINNED_SHA: &str = "0123456789012345678901234567890123456789"; + const BUNDLE_SHA: &str = "83c2518a9e6abbda0c44592c3483de459198f887"; struct CountingBackend { - latest_calls: AtomicUsize, + pinned_calls: AtomicUsize, custom_calls: AtomicUsize, custom_fails: bool, } @@ -112,14 +113,14 @@ mod tests { impl CountingBackend { fn new(custom_fails: bool) -> Self { Self { - latest_calls: AtomicUsize::new(0), + pinned_calls: AtomicUsize::new(0), custom_calls: AtomicUsize::new(0), custom_fails, } } } - impl AcquisitionBackend for CountingBackend { + impl SourceBackend for CountingBackend { fn load_custom(&self, _path: &str) -> Result { let _ = self.custom_calls.fetch_add(1, Ordering::SeqCst); if self.custom_fails { @@ -128,63 +129,42 @@ mod tests { let identity = SourceIdentity::Custom { digest: "custom-content-digest".to_owned(), }; - Ok(fixture_snapshot( - identity, - SourceKind::Custom, - Provenance::UserManaged, - )) - } - - fn load_commit( - &self, - _commit: Oid, - _request: &TypeshedRequest, - ) -> Result { - Err(BackendError::Download) + Ok(fixture_snapshot(identity, SourceKind::Custom)) } - fn load_latest(&self, _request: &TypeshedRequest) -> Result { - let _ = self.latest_calls.fetch_add(1, Ordering::SeqCst); + fn load_pinned(&self, commit: Oid, explicit: bool) -> Result { + let _ = self.pinned_calls.fetch_add(1, Ordering::SeqCst); // Widen the overlap window: every caller races into `snapshot`, but // OnceLock must permit exactly one backend call. thread::sleep(Duration::from_millis(10)); - let commit = Oid::from_hex(LATEST_SHA).expect("valid latest fixture oid"); let identity = SourceIdentity::Commit { commit, - pinned: false, + pinned: explicit, }; - Ok(fixture_snapshot( - identity, - SourceKind::Latest, - Provenance::GithubTlsAttested, - )) + Ok(fixture_snapshot(identity, SourceKind::ExactCommit)) } fn load_bundled(&self) -> Result { - let commit = Oid::from_hex("83c2518a9e6abbda0c44592c3483de459198f887") - .expect("valid bundle fixture oid"); + let commit = Oid::from_hex(BUNDLE_SHA).expect("valid bundle fixture oid"); Ok(fixture_snapshot( SourceIdentity::Bundled { commit }, SourceKind::Bundled, - Provenance::BundleVetted, )) } } - fn request(selection: SourceSelection) -> TypeshedRequest { + fn pinned_request() -> TypeshedRequest { + let commit = Oid::from_hex(PINNED_SHA).expect("valid pinned fixture oid"); TypeshedRequest { - selection, - verify_content: true, - use_cache: true, - url_template: None, + selection: SourceSelection::Pinned { + commit, + explicit: true, + }, + store_path: None, } } - fn fixture_snapshot( - identity: SourceIdentity, - active_source: SourceKind, - provenance: Provenance, - ) -> Snapshot { + fn fixture_snapshot(identity: SourceIdentity, active_source: SourceKind) -> Snapshot { let commit = identity.commit(); let archive = Archive::new(vec![ ArchiveEntry { @@ -202,17 +182,8 @@ mod tests { active_source, commit, tree: commit, - transport: if active_source == SourceKind::Bundled { - Transport::EmbeddedZip - } else if active_source == SourceKind::Custom { - Transport::CustomPath - } else { - Transport::Codeload - }, license_status: LicenseStatus::Approved, license_reference: None, - provenance, - signed_release: false, warnings: Vec::new(), }; let uri_identity = identity.uri_component(); @@ -226,12 +197,10 @@ mod tests { } #[test] - fn concurrent_callers_share_one_arc_and_one_latest_resolution() { + fn concurrent_callers_share_one_arc_and_one_resolution() { let backend = Arc::new(CountingBackend::new(false)); - let manager = TypeshedManager::new( - request(SourceSelection::Latest), - Arc::::clone(&backend), - ); + let manager = + TypeshedManager::new(pinned_request(), Arc::::clone(&backend)); let snapshots = thread::scope(|scope| { let handles: Vec<_> = (0..12) .map(|_| scope.spawn(|| manager.snapshot().expect("snapshot"))) @@ -241,7 +210,7 @@ mod tests { .map(|handle| handle.join().expect("thread")) .collect::>() }); - assert_eq!(backend.latest_calls.load(Ordering::SeqCst), 1); + assert_eq!(backend.pinned_calls.load(Ordering::SeqCst), 1); let first = snapshots.first().expect("at least one snapshot"); assert!(snapshots .iter() @@ -253,9 +222,12 @@ mod tests { fn terminal_failure_is_memoized_without_fallback_retry() { let backend = Arc::new(CountingBackend::new(true)); let manager = TypeshedManager::new( - request(SourceSelection::Custom { - path: "/workspace/custom-typeshed".to_owned(), - }), + TypeshedRequest { + selection: SourceSelection::Custom { + path: "/workspace/custom-typeshed".to_owned(), + }, + store_path: None, + }, Arc::::clone(&backend), ); assert!(manager.snapshot().is_err()); @@ -267,60 +239,9 @@ mod tests { #[test] fn status_is_cloned_from_the_ready_snapshot() { let backend = Arc::new(CountingBackend::new(false)); - let manager = TypeshedManager::new(request(SourceSelection::Latest), backend); + let manager = TypeshedManager::new(pinned_request(), backend); let snapshot = manager.snapshot().expect("snapshot"); let status = manager.status().expect("status"); assert_eq!(status, snapshot.status); } - - #[test] - fn public_errors_never_echo_the_configured_mirror_url() { - let commit = Oid::from_hex(LATEST_SHA).expect("valid test oid"); - let backend = Arc::new(CountingBackend::new(false)); - let mut request = request(SourceSelection::ExactCommit { commit }); - request.url_template = - Some("https://secret-user:secret-token@example.invalid/typeshed/{sha}.zip".to_owned()); - let manager = TypeshedManager::new(request, backend); - let message = manager - .snapshot() - .expect_err("commit must fail") - .to_string(); - assert!(!message.contains("secret-user")); - assert!(!message.contains("secret-token")); - assert!(!message.contains("example.invalid")); - } - - #[test] - fn debug_output_never_echoes_configured_mirror_credentials() { - let backend = Arc::new(CountingBackend::new(false)); - let mut request = request(SourceSelection::Latest); - request.url_template = Some( - "https://secret-user:secret-password@example.invalid/{sha}.zip?token=query-secret" - .to_owned(), - ); - let request_debug = format!("{request:?}"); - let manager = TypeshedManager::new(request, backend); - let manager_debug = format!("{manager:?}"); - - for (label, debug) in [ - ("request", request_debug.as_str()), - ("manager", manager_debug.as_str()), - ] { - assert!( - debug.contains("mirror_configured: true"), - "{label}: {debug}" - ); - for secret in [ - "secret-user", - "secret-password", - "example.invalid", - "query-secret", - ] { - assert!( - !debug.contains(secret), - "{label} debug leaked `{secret}`: {debug}" - ); - } - } - } } diff --git a/crates/basilisk-stubs/src/typeshed/mod.rs b/crates/basilisk-stubs/src/typeshed/mod.rs index 44d645af..186a2ddd 100644 --- a/crates/basilisk-stubs/src/typeshed/mod.rs +++ b/crates/basilisk-stubs/src/typeshed/mod.rs @@ -1,33 +1,28 @@ //! Implements [STUBRES-OVERVIEW], [TYPESHEDRT-OVERVIEW], and -//! [STUBRES-TYPESHED-ACQUIRE]. See +//! [STUBRES-TYPESHED-OFFLINE]. See //! docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-OVERVIEW //! -//! Runtime `python/typeshed` acquisition core. +//! Runtime `python/typeshed` source resolution core. //! -//! Basilisk **never clones** ([STUBRES-TYPESHED-ACQUIRE]). It downloads a commit -//! archive over HTTPS, streams it through four activation gates (Safety, Shape, -//! License, Content), caches the accepted bytes as an immutable ZIP, and reads -//! `.pyi` through an archive VFS. +//! There are exactly two sources, both already on this machine when checking +//! starts ([STUBRES-TYPESHED]): a **pinned commit** (the embedded bundle when +//! the SHA is the bundled one, else that commit's [`store`] entry) or a +//! **custom folder**. Resolution performs no network activity of any kind — +//! structurally: this crate links no HTTP client, so the analysis path cannot +//! reach the network even by mistake ([STUBRES-TYPESHED-OFFLINE]). Downloading +//! lives in the separate `basilisk-typeshed-fetch` crate and runs only on +//! explicit user action ([STUBRES-TYPESHED-DOWNLOAD]). //! -//! This module tree is deliberately transport-agnostic. The pure security and -//! verification logic — Git-tree reconstruction ([`gittree`]), the activation -//! gates, the in-memory [`archive::Archive`] model, and the composable -//! source-status warnings ([`warning`]) — is defined over an in-memory archive -//! so it is fully unit-testable with no network. The HTTP transport and on-disk -//! cache are thin adapters over the same model, added at a clean seam. -//! -//! **Security boundary** ([STUBRES-TYPESHED-ACQUIRE]): a reported SHA alone -//! proves nothing about the bytes the checker reads. Trusted GitHub metadata -//! binds a commit to its tree; the [`gittree`] reconstruction binds the analysed -//! bytes to that tree. Verification proves *integrity* (bytes match the SHA), -//! never *authenticity* (that the SHA is an official typeshed release) — no -//! release signature is validated, so official provenance ultimately trusts -//! GitHub/TLS. Custom and verification-disabled sources are never labelled -//! official. +//! **Security boundary** ([STUBRES-TYPESHED-PIN]): a pin is a verification — +//! the stored raw commit object must hash to the pinned SHA, and the stored +//! tree must re-hash to the root tree that verified commit object names. This +//! proves *integrity* since acquisition (bytes match the SHA), never +//! *authenticity* (that the SHA is an official typeshed commit) — no release +//! signature exists, so official provenance ultimately trusts GitHub/TLS at +//! download time. There is no verification waiver. pub mod archive; pub mod bundle; -pub mod cache; pub mod codec; pub mod gate; pub mod gittree; @@ -36,6 +31,6 @@ pub mod runtime; pub mod selector; pub mod snapshot; pub mod source; -pub mod transport; +pub mod store; pub mod versions; pub mod warning; diff --git a/crates/basilisk-stubs/src/typeshed/runtime.rs b/crates/basilisk-stubs/src/typeshed/runtime.rs index 37a27d27..543c5619 100644 --- a/crates/basilisk-stubs/src/typeshed/runtime.rs +++ b/crates/basilisk-stubs/src/typeshed/runtime.rs @@ -1,234 +1,56 @@ -//! Production acquisition backend over injected HTTPS transport and disk cache. -//! Implements the source model and work contract in [TYPESHEDRT-MODEL], -//! [TYPESHEDRT-WORK], and [TYPESHEDRT-ACCEPTANCE]. +//! Production source backend over the embedded bundle, the local store, and +//! user-managed custom trees. Implements the source model and work contract in +//! [TYPESHEDRT-MODEL], [TYPESHEDRT-WORK], and [TYPESHEDRT-SEGREGATION]. +//! +//! Everything here is a local read: this crate carries no HTTP client, so the +//! analysis path cannot reach the network even by mistake +//! ([STUBRES-TYPESHED-OFFLINE]). Downloading lives in the separate +//! `basilisk-typeshed-fetch` crate, invoked only by explicit user action. mod custom; -use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; -use super::archive::{Archive, ArchiveEntry}; -use super::bundle::{approved_license_manifest, bundled_snapshot}; -use super::cache::{CacheKey, CacheRecord, CachedArchive, CachedTreeFile, DiskCache}; -use super::codec::{decode_zip, DecodeLimits, ZipLayout}; -use super::gate::manifest::sha256_hex; -use super::gate::{run_activation, GateConfig, GateError, SafetyLimits}; -use super::gittree::{git_blob_oid, reconstruct_root_tree_oid, FileMode, GitFile, Oid}; +use super::bundle::bundled_snapshot; +use super::gittree::Oid; use super::manager::TypeshedManager; -use super::selector::{AcquisitionBackend, BackendError}; +use super::selector::{BackendError, SourceBackend}; use super::snapshot::Snapshot; -use super::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, StatusWarning, - Transport as SourceTransport, TypeshedRequest, TypeshedStatus, -}; -use super::transport::{CommitMetadata, HttpsTransport, Transport, TransportError, TreeEntry}; - -#[derive(Debug, Clone, PartialEq, Eq)] -struct TrustedTree { - root: Oid, - files: BTreeMap, -} +use super::source::TypeshedRequest; +use super::store::{self, StoreError}; /// Production policy backend shared by CLI/LSP/MCP managers. +#[derive(Debug)] pub struct RuntimeBackend { - transport: Arc, - cache: Option, -} - -impl std::fmt::Debug for RuntimeBackend { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("RuntimeBackend") - .field("cache_enabled", &self.cache.is_some()) - .finish_non_exhaustive() - } + store_root: Option, } impl RuntimeBackend { - /// Construct a backend from the concrete HTTPS adapter and optional cache. + /// Construct a backend resolving pins from `store_root`, or the per-user + /// OS default when `None`. #[must_use] - pub fn new(transport: Arc, cache: Option) -> Self { - Self { transport, cache } - } - - fn load_resolved( - &self, - metadata: &CommitMetadata, - request: &TypeshedRequest, - pinned: bool, - ) -> Result { - if request.use_cache { - if let Some(cached) = self.load_cache(metadata.commit, pinned) { - let cached_result = validate_cache_record(metadata.commit, metadata.tree, &cached) - .and_then(|()| trusted_from_cache(metadata.commit, &cached)) - .and_then(|trusted| { - activate_zip( - metadata.commit, - &cached.zip, - &trusted, - request, - cached_transport(&cached)?, - pinned, - ) - }); - if let Ok(snapshot) = cached_result { - return Ok(snapshot); - } - tracing::warn!("typeshed cached candidate failed gates; reacquiring archive"); - } - } - let retain_tree_metadata = - request.verify_content || (request.use_cache && self.cache.is_some()); - let trusted = if retain_tree_metadata { - let entries = self - .transport - .fetch_tree(metadata.tree) - .map_err(|_error| BackendError::Metadata)?; - trusted_from_entries(metadata.tree, entries)? - } else { - TrustedTree { - root: metadata.tree, - files: BTreeMap::new(), - } - }; - let bytes = self - .transport - .fetch_archive(metadata.commit) - .map_err(|_error| BackendError::Download)?; - let source_transport = self.transport.archive_transport(); - if !matches!( - source_transport, - SourceTransport::Codeload | SourceTransport::Mirror - ) { - return Err(BackendError::Validation); - } - let snapshot = activate_zip( - metadata.commit, - &bytes, - &trusted, - request, - source_transport, - pinned, - )?; - self.store_cache(metadata.commit, &trusted, &bytes, request, source_transport); - Ok(snapshot) - } - - /// Look up `commit` in the cache. - /// - /// `pinned` selects the reuse window. An explicit `typeshed-commit` pin is - /// content-addressed and re-hashed on every load, so age tells us nothing - /// and expiring it would only force a needless network round-trip. `Latest` - /// resolves the moving `main` reference, where stale bytes really would - /// misrepresent the selection, so it keeps the 24-hour window - /// ([STUBRES-TYPESHED-ACQUIRE]). - fn load_cache(&self, commit: Oid, pinned: bool) -> Option { - let Some(cache) = &self.cache else { - return None; - }; - let key = cache_key(commit); - let found = if pinned { - cache.load_pinned(&key) - } else { - cache.load_fresh(&key, unix_seconds_now()) - }; - match found { - Ok(cached) => cached, - Err(_error) => { - // A cache is an optimization, never an alternate trust root. - // Corrupt/incomplete generations are ignored and acquisition - // obtains fresh official bytes through the normal gates. - tracing::warn!("typeshed cache reuse failed; reacquiring archive"); - None - } - } - } - - fn store_cache( - &self, - commit: Oid, - trusted: &TrustedTree, - zip: &[u8], - request: &TypeshedRequest, - source_transport: SourceTransport, - ) { - if !request.use_cache { - return; - } - let Some(cache) = &self.cache else { - return; - }; - let record = CacheRecord { - commit: Some(commit.to_hex()), - tree: Some(trusted.root.to_hex()), - zip_sha256: sha256_hex(zip), - verified: request.verify_content, - transport: Some(transport_label(source_transport).to_owned()), - acquired_at_unix_seconds: unix_seconds_now(), - tree_files: trusted - .files - .values() - .map(|entry| CachedTreeFile { - path: entry.path.clone(), - oid: entry.oid.to_hex(), - mode: entry.mode.as_str().to_owned(), - }) - .collect(), - }; - if cache.store(&cache_key(commit), zip, &record).is_err() { - tracing::warn!("typeshed cache store failed"); - } + pub const fn new(store_root: Option) -> Self { + Self { store_root } } } -impl AcquisitionBackend for RuntimeBackend { +impl SourceBackend for RuntimeBackend { fn load_custom(&self, path: &str) -> Result { custom::load_custom_snapshot(path) } - fn load_commit( - &self, - commit: Oid, - request: &TypeshedRequest, - ) -> Result { - if request.use_cache { - if let Some(cached) = self.load_cache(commit, true) { - let cached_result = trusted_from_cache(commit, &cached).and_then(|trusted| { - activate_zip( - commit, - &cached.zip, - &trusted, - request, - cached_transport(&cached)?, - true, - ) - }); - if let Ok(snapshot) = cached_result { - return Ok(snapshot); - } - tracing::warn!("typeshed exact cache failed gates; reacquiring metadata"); - } - } - let metadata = self - .transport - .resolve_commit(commit) - .map_err(|_error| BackendError::Metadata)?; - if metadata.commit != commit { - return Err(BackendError::Metadata); - } - self.load_resolved(&metadata, request, true) - } - - fn load_latest(&self, request: &TypeshedRequest) -> Result { - // Resolve B before any cache lookup. Therefore cached unpinned A can - // never be selected when Latest moves or metadata resolution fails. - let resolved = self - .transport - .resolve_latest() - .map_err(|_error| BackendError::Metadata)?; - self.load_resolved(&resolved, request, false) + fn load_pinned(&self, commit: Oid, explicit: bool) -> Result { + let root = self + .store_root + .clone() + .or_else(default_store_path) + .ok_or(BackendError::Missing)?; + store::read_snapshot(&root, commit, explicit).map_err(|error| match error { + StoreError::Missing => BackendError::Missing, + StoreError::Corrupt => BackendError::Corrupt, + StoreError::LicenseChanged => BackendError::LicenseChanged, + }) } fn load_bundled(&self) -> Result { @@ -240,254 +62,26 @@ impl AcquisitionBackend for RuntimeBackend { #[must_use] pub fn manager_for_request( request: TypeshedRequest, - transport: Arc, - cache: Option, + backend: Arc, ) -> TypeshedManager { - TypeshedManager::new(request, Arc::new(RuntimeBackend::new(transport, cache))) + TypeshedManager::new(request, backend) } -/// Construct a production manager with authenticated HTTPS and the canonical -/// per-user OS cache when caching is enabled. -/// -/// # Errors -/// -/// Returns a redacted transport configuration error for an invalid mirror. -pub fn production_manager( - request: TypeshedRequest, - cache_path: Option, -) -> Result { - let cache = select_cache(request.use_cache, cache_path); - let transport = Arc::new(HttpsTransport::new(request.url_template.clone())?); - Ok(manager_for_request(request, transport, cache)) -} - -/// Resolve which disk cache an acquisition may use. -/// -/// Separated from [`production_manager`] so the choice is reachable without -/// constructing an HTTPS transport, which the surrounding function does and -/// which no unit test can exercise offline. -/// -/// `typeshed-cache = false` disables reuse outright and outranks any configured -/// directory — otherwise a project that had switched caching off would silently -/// keep reusing bytes from a path it also configured. `typeshed-cache-path` -/// then relocates storage, and only its absence falls back to the canonical -/// per-user OS cache ([STUBRES-TYPESHED-CONFIG]). +/// Construct the production manager. Resolution is local and infallible to +/// build — a failing source surfaces from [`TypeshedManager::snapshot`]. #[must_use] -pub fn select_cache(use_cache: bool, cache_path: Option) -> Option { - if !use_cache { - return None; - } - cache_path.map(DiskCache::new).or_else(default_cache) +pub fn production_manager(request: TypeshedRequest) -> TypeshedManager { + let backend = Arc::new(RuntimeBackend::new(request.store_path.clone())); + manager_for_request(request, backend) } -/// Canonical per-user typeshed cache directory for this platform. +/// Canonical per-user typeshed store directory for this platform +/// ([STUBRES-TYPESHED-STORE]). #[must_use] -pub fn default_cache_path() -> Option { +pub fn default_store_path() -> Option { platform_cache_base().map(|base| base.join("basilisk").join("typeshed")) } -/// Canonical disk cache, or `None` when the platform exposes no user cache -/// location in the current environment. -#[must_use] -pub fn default_cache() -> Option { - default_cache_path().map(DiskCache::new) -} - -fn activate_zip( - commit: Oid, - zip: &[u8], - trusted: &TrustedTree, - request: &TypeshedRequest, - transport: SourceTransport, - pinned: bool, -) -> Result { - let decoded = decode_zip(zip, ZipLayout::CodeloadPrefixed, &DecodeLimits::default()) - .map_err(|_error| BackendError::Validation)?; - let archive = if request.verify_content { - bind_trusted_files(decoded, trusted)? - } else { - decoded - }; - let approved = approved_license_manifest().map_err(|_error| BackendError::Bundle)?; - let identity = SourceIdentity::Commit { commit, pinned }; - let config = GateConfig { - limits: SafetyLimits::default(), - approved_license: approved, - expected_root_tree: trusted.root, - verify_content: request.verify_content, - }; - let activation = run_activation(archive, &config, identity.uri_component()) - .map_err(|error| gate_error(&error))?; - let provenance = if request.verify_content { - Provenance::GithubTlsAttested - } else { - Provenance::Unverified - }; - let status = TypeshedStatus { - active_source: if pinned { - SourceKind::ExactCommit - } else { - SourceKind::Latest - }, - commit: Some(commit), - tree: activation.root_tree, - transport, - license_status: LicenseStatus::Approved, - license_reference: Some(format!( - "https://github.com/python/typeshed/blob/{commit}/LICENSE" - )), - provenance, - signed_release: false, - warnings: StatusWarning::list(&activation.warnings), - }; - Snapshot::build(identity, status, activation.vfs, None) - .map_err(|_error| BackendError::Validation) -} - -fn bind_trusted_files(archive: Archive, trusted: &TrustedTree) -> Result { - if trusted.files.is_empty() { - return Ok(archive); - } - if archive.len() != trusted.files.len() { - return Err(BackendError::Validation); - } - let mut seen = BTreeSet::new(); - let mut entries = Vec::with_capacity(archive.len()); - for entry in archive.entries() { - let metadata = trusted - .files - .get(&entry.path) - .ok_or(BackendError::Validation)?; - if !matches!(metadata.mode, FileMode::Regular | FileMode::Executable) - || git_blob_oid(&entry.data) != metadata.oid - || !seen.insert(entry.path.clone()) - { - return Err(BackendError::Validation); - } - entries.push(ArchiveEntry { - path: entry.path.clone(), - mode: metadata.mode, - data: entry.data.clone(), - }); - } - if seen.len() != trusted.files.len() { - return Err(BackendError::Validation); - } - Ok(Archive::new(entries)) -} - -fn validate_cache_record( - commit: Oid, - tree: Oid, - cached: &CachedArchive, -) -> Result<(), BackendError> { - let expected_commit = commit.to_hex(); - let expected_tree = tree.to_hex(); - if cached.record.commit.as_deref() != Some(expected_commit.as_str()) - || cached.record.tree.as_deref() != Some(expected_tree.as_str()) - { - return Err(BackendError::Validation); - } - Ok(()) -} - -fn trusted_from_cache(commit: Oid, cached: &CachedArchive) -> Result { - let expected_commit = commit.to_hex(); - if cached.record.commit.as_deref() != Some(expected_commit.as_str()) { - return Err(BackendError::Validation); - } - let tree = cached - .record - .tree - .as_deref() - .ok_or(BackendError::Validation) - .and_then(|value| Oid::from_hex(value).map_err(|_error| BackendError::Validation))?; - let entries = cached - .record - .tree_files - .iter() - .map(|file| { - let oid = Oid::from_hex(&file.oid).map_err(|_error| BackendError::Validation)?; - let mode = match file.mode.as_str() { - "100644" => FileMode::Regular, - "100755" => FileMode::Executable, - "120000" => FileMode::Symlink, - "160000" => FileMode::Submodule, - _ => return Err(BackendError::Validation), - }; - Ok(TreeEntry { - path: file.path.clone(), - oid, - mode, - }) - }) - .collect::, _>>()?; - if entries.is_empty() { - return Ok(TrustedTree { - root: tree, - files: BTreeMap::new(), - }); - } - trusted_from_entries(tree, entries) -} - -fn trusted_from_entries(root: Oid, entries: Vec) -> Result { - let mut files = BTreeMap::new(); - for entry in entries { - if files.insert(entry.path.clone(), entry).is_some() { - return Err(BackendError::Validation); - } - } - let git_files: Vec<_> = files - .values() - .map(|entry| GitFile { - path: entry.path.clone(), - oid: entry.oid, - mode: entry.mode, - }) - .collect(); - let reconstructed = - reconstruct_root_tree_oid(&git_files).map_err(|_error| BackendError::Validation)?; - if reconstructed != root { - return Err(BackendError::Validation); - } - Ok(TrustedTree { root, files }) -} - -fn cache_key(commit: Oid) -> CacheKey { - CacheKey::from_identity(&commit.to_hex()) -} - -fn unix_seconds_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs()) -} - -fn cached_transport(cached: &CachedArchive) -> Result { - match cached.record.transport.as_deref() { - Some("codeload") => Ok(SourceTransport::Codeload), - Some("mirror") => Ok(SourceTransport::Mirror), - _ => Err(BackendError::Validation), - } -} - -fn transport_label(transport: SourceTransport) -> &'static str { - match transport { - SourceTransport::Codeload => "codeload", - SourceTransport::Mirror => "mirror", - SourceTransport::CustomPath | SourceTransport::EmbeddedZip => "invalid", - } -} - -fn gate_error(error: &GateError) -> BackendError { - if matches!(error, GateError::License(_)) { - BackendError::LicenseChanged - } else { - BackendError::Validation - } -} - #[cfg(target_os = "windows")] fn platform_cache_base() -> Option { std::env::var_os("LOCALAPPDATA").map(PathBuf::from) @@ -514,6 +108,6 @@ fn platform_cache_base() -> Option { #[cfg(test)] #[expect( clippy::expect_used, - reason = "test-only acquisition fixtures use fixed ZIPs and SHA constants" + reason = "test-only resolution fixtures use fixed embedded assets and SHA constants" )] mod tests; diff --git a/crates/basilisk-stubs/src/typeshed/runtime/custom.rs b/crates/basilisk-stubs/src/typeshed/runtime/custom.rs index 8c80dc90..05d296c3 100644 --- a/crates/basilisk-stubs/src/typeshed/runtime/custom.rs +++ b/crates/basilisk-stubs/src/typeshed/runtime/custom.rs @@ -10,9 +10,7 @@ use super::super::gate::{safety_gate, SafetyLimits}; use super::super::gittree::FileMode; use super::super::selector::BackendError; use super::super::snapshot::Snapshot; -use super::super::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, Transport, TypeshedStatus, -}; +use super::super::source::{LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus}; /// Load a custom tree into one immutable, content-identified snapshot. pub(super) fn load_custom_snapshot(path: &str) -> Result { @@ -40,11 +38,8 @@ pub(super) fn load_custom_snapshot(path: &str) -> Result active_source: SourceKind::Custom, commit: None, tree: None, - transport: Transport::CustomPath, license_status: LicenseStatus::NotSupplied, license_reference: None, - provenance: Provenance::UserManaged, - signed_release: false, warnings: Vec::new(), }; let uri_identity = identity.uri_component(); diff --git a/crates/basilisk-stubs/src/typeshed/runtime/tests.rs b/crates/basilisk-stubs/src/typeshed/runtime/tests.rs index 671fb8c2..5b61c910 100644 --- a/crates/basilisk-stubs/src/typeshed/runtime/tests.rs +++ b/crates/basilisk-stubs/src/typeshed/runtime/tests.rs @@ -1,773 +1,165 @@ -use std::collections::HashMap; -use std::io::{Cursor, Write as _}; -use std::path::Path; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +//! Backend and manager wiring over real local sources: the embedded bundle, a +//! temp-dir store, and custom trees. The verification chain itself is covered +//! in `store::tests`; these tests prove the production wiring resolves only +//! from this machine and fails hard when a source is absent +//! ([STUBRES-TYPESHED-OFFLINE], [TYPESHEDRT-ACCEPTANCE]). -use zip::write::{SimpleFileOptions, ZipWriter}; -use zip::CompressionMethod; +use std::fs; -use super::super::bundle::{bundled_commit_sha, bundled_snapshot}; -use super::super::gittree::{git_blob_oid, reconstruct_root_tree_oid, GitFile}; -use super::super::source::{SourceIdentity, SourceSelection}; +use super::super::gittree::Oid; +use super::super::selector::{BackendError, SelectionError}; +use super::super::source::{SourceKind, SourceSelection, TypeshedRequest}; use super::*; -mod integrity; +const OTHER_SHA: &str = "0123456789012345678901234567890123456789"; -const A_SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const B_SHA: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - -#[derive(Debug, Clone)] -struct Fixture { - metadata: CommitMetadata, - tree: Vec, - zip: Vec, -} - -#[derive(Debug)] -struct FakeTransport { - latest: Option, - commits: HashMap, - trees: HashMap>, - archives: HashMap>, - origin: SourceTransport, - latest_calls: AtomicUsize, - commit_calls: AtomicUsize, - tree_calls: AtomicUsize, - archive_calls: AtomicUsize, - fetched: Mutex>, -} - -impl FakeTransport { - fn new(latest: Option, fixtures: &[Fixture], origin: SourceTransport) -> Self { - let commits = fixtures - .iter() - .map(|fixture| (fixture.metadata.commit, fixture.metadata.clone())) - .collect(); - let trees = fixtures - .iter() - .map(|fixture| (fixture.metadata.tree, fixture.tree.clone())) - .collect(); - let archives = fixtures - .iter() - .map(|fixture| (fixture.metadata.commit, fixture.zip.clone())) - .collect(); - let latest = latest.and_then(|commit| { - fixtures - .iter() - .find(|fixture| fixture.metadata.commit == commit) - .map(|fixture| fixture.metadata.clone()) - }); - Self { - latest, - commits, - trees, - archives, - origin, - latest_calls: AtomicUsize::new(0), - commit_calls: AtomicUsize::new(0), - tree_calls: AtomicUsize::new(0), - archive_calls: AtomicUsize::new(0), - fetched: Mutex::new(Vec::new()), - } - } - - fn offline() -> Self { - Self::new(None, &[], SourceTransport::Codeload) - } -} - -impl Transport for FakeTransport { - fn resolve_latest(&self) -> Result { - let _ = self.latest_calls.fetch_add(1, Ordering::SeqCst); - self.latest.clone().ok_or(TransportError::Metadata) - } - - fn resolve_commit(&self, commit: Oid) -> Result { - let _ = self.commit_calls.fetch_add(1, Ordering::SeqCst); - self.commits - .get(&commit) - .cloned() - .ok_or(TransportError::Metadata) - } - - fn fetch_tree(&self, root_tree: Oid) -> Result, TransportError> { - let _ = self.tree_calls.fetch_add(1, Ordering::SeqCst); - self.trees - .get(&root_tree) - .cloned() - .ok_or(TransportError::Metadata) - } - - fn fetch_archive(&self, commit: Oid) -> Result, TransportError> { - let _ = self.archive_calls.fetch_add(1, Ordering::SeqCst); - self.fetched.lock().expect("fetched lock").push(commit); - self.archives - .get(&commit) - .cloned() - .ok_or(TransportError::Download) - } - - fn archive_transport(&self) -> SourceTransport { - self.origin - } -} - -fn oid(value: &str) -> Oid { - Oid::from_hex(value).expect("fixture oid") -} - -fn request(selection: SourceSelection, verify_content: bool) -> TypeshedRequest { +fn pinned_request(commit: Oid, explicit: bool, store: &std::path::Path) -> TypeshedRequest { TypeshedRequest { - selection, - verify_content, - use_cache: true, - url_template: None, + selection: SourceSelection::Pinned { commit, explicit }, + store_path: Some(store.to_path_buf()), } } -fn fixture(commit: &str, marker: &str) -> Fixture { - let license = bundled_snapshot() - .expect("bundle") - .vfs - .read("LICENSE") - .expect("bundle license") - .to_vec(); - fixture_with_license(commit, marker, license) -} - -fn fixture_with_license(commit: &str, marker: &str, license: Vec) -> Fixture { - let lowercase = marker.to_ascii_lowercase(); - let files = vec![ - ("LICENSE".to_owned(), license, FileMode::Regular, 0o644), - ( - "stdlib/VERSIONS".to_owned(), - format!("sentinel: 3.0-\n# {marker}\n").into_bytes(), - FileMode::Regular, - 0o644, - ), - ( - "stdlib/sentinel.pyi".to_owned(), - format!("VALUE: str # {marker}\n").into_bytes(), - FileMode::Regular, - 0o644, - ), - ( - format!("stdlib/{lowercase}_only.pyi"), - b"ONLY: int\n".to_vec(), - FileMode::Regular, - 0o644, - ), - ( - format!("stubs/{lowercase}_demo/demo.pyi"), - b"VALUE: int\n".to_vec(), - FileMode::Regular, - 0o644, - ), - ]; - make_fixture(commit, files) -} - -fn make_fixture(commit: &str, files: Vec<(String, Vec, FileMode, u32)>) -> Fixture { - make_fixture_with_compression(commit, files, CompressionMethod::Stored) -} - -fn make_fixture_with_compression( - commit: &str, - files: Vec<(String, Vec, FileMode, u32)>, - compression: CompressionMethod, -) -> Fixture { - let tree: Vec<_> = files - .iter() - .map(|(path, data, mode, _zip_mode)| TreeEntry { - path: path.clone(), - oid: git_blob_oid(data), - mode: *mode, - }) - .collect(); - let git_files: Vec<_> = tree +/// The out-of-the-box conformance path: no configuration, no store entry, no +/// network — the bundled commit resolves offline and reports UNPINNED. +#[test] +fn the_bundled_default_resolves_offline_with_unpinned() { + let store = tempfile::tempdir().expect("tempdir"); + let commit = Oid::from_hex(super::super::bundle::bundled_commit_sha()).expect("bundled sha"); + let manager = production_manager(pinned_request(commit, false, store.path())); + let snapshot = manager.snapshot().expect("bundled default resolves"); + assert_eq!(snapshot.status.active_source, SourceKind::Bundled); + let codes: Vec<&str> = snapshot + .status + .warnings .iter() - .map(|entry| GitFile { - path: entry.path.clone(), - oid: entry.oid, - mode: entry.mode, - }) + .map(|warning| warning.code.as_str()) .collect(); - let root = reconstruct_root_tree_oid(&git_files).expect("fixture root"); - let mut zip = Vec::new(); - { - let mut writer = ZipWriter::new(Cursor::new(&mut zip)); - for (path, data, _trusted_mode, zip_mode) in files { - let options = SimpleFileOptions::default() - .compression_method(compression) - .unix_permissions(zip_mode); - writer - .start_file(format!("typeshed-{commit}/{path}"), options) - .expect("zip entry"); - writer.write_all(&data).expect("zip bytes"); - } - let _ = writer.finish().expect("zip finish"); - } - Fixture { - metadata: CommitMetadata { - commit: oid(commit), - tree: root, - }, - tree, - zip, - } -} - -fn manager( - request: TypeshedRequest, - fake: Arc, - cache: Option, -) -> TypeshedManager { - let transport: Arc = fake; - manager_for_request(request, transport, cache) + assert_eq!(codes, vec!["UNPINNED"]); + // Real bodies, offline. + assert!(snapshot.read_stub("os").is_some()); } +/// An explicit pin of the bundled commit is deterministic: same bytes, no +/// UNPINNED, still zero store/network involvement. #[test] -fn latest_resolves_b_before_cache_and_all_views_come_from_b() { - let cache_dir = tempfile::tempdir().expect("cache dir"); - let cache = DiskCache::new(cache_dir.path()); - let a = fixture(A_SHA, "A"); - let seed = Arc::new(FakeTransport::new( - Some(a.metadata.commit), - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let _ = manager( - request(SourceSelection::Latest, true), - seed, - Some(cache.clone()), - ) - .snapshot() - .expect("seed A"); - - let b = fixture(B_SHA, "B"); - let remote = Arc::new(FakeTransport::new( - Some(b.metadata.commit), - std::slice::from_ref(&b), - SourceTransport::Codeload, - )); - let snapshot = manager( - request(SourceSelection::Latest, true), - Arc::clone(&remote), - Some(cache), - ) - .snapshot() - .expect("activate B"); - assert_eq!(snapshot.status.commit, Some(b.metadata.commit)); - assert!(snapshot.versions().is_some_and(|text| text.contains("# B"))); - assert!(snapshot.module_index.path("b_only").is_some()); - assert!(snapshot.module_index.path("a_only").is_none()); - assert_eq!( - snapshot.read_stub("sentinel").map(|(_, body)| body), - Some("VALUE: str # B\n") - ); - assert_eq!( - snapshot.distribution_index.distribution("demo"), - Some("types-b_demo") - ); - assert_eq!(remote.latest_calls.load(Ordering::SeqCst), 1); - assert_eq!( - *remote.fetched.lock().expect("fetched"), - vec![b.metadata.commit] - ); -} - -#[test] -fn exact_pin_reuses_cache_offline_and_remains_pinned() { - let cache_dir = tempfile::tempdir().expect("cache dir"); - let cache = DiskCache::new(cache_dir.path()); - let a = fixture(A_SHA, "A"); - let online = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let exact = SourceSelection::ExactCommit { - commit: a.metadata.commit, - }; - let _ = manager( - request(exact.clone(), true), - Arc::clone(&online), - Some(cache.clone()), - ) - .snapshot() - .expect("online pin"); - let offline = Arc::new(FakeTransport::offline()); - let snapshot = manager(request(exact, true), Arc::clone(&offline), Some(cache)) - .snapshot() - .expect("offline cached pin"); - assert!(matches!( - snapshot.identity, - SourceIdentity::Commit { pinned: true, .. } - )); - assert_eq!(offline.commit_calls.load(Ordering::SeqCst), 0); - assert_eq!(offline.tree_calls.load(Ordering::SeqCst), 0); - assert_eq!(offline.archive_calls.load(Ordering::SeqCst), 0); -} - -/// Age out every generation under `key` by stamping the oldest possible -/// timestamp onto its record. Zero is the legacy-record sentinel, so this also -/// covers entries written before the field existed. -fn age_out_cached_bytes(cache_dir: &Path, key: &str) { - let generations = cache_dir.join(key).join("generations"); - for entry in std::fs::read_dir(&generations).expect("generations dir") { - let meta = entry.expect("generation entry").path().join("meta.json"); - let mut record: CacheRecord = - serde_json::from_slice(&std::fs::read(&meta).expect("cache metadata")) - .expect("valid metadata"); - record.acquired_at_unix_seconds = 0; - std::fs::write( - &meta, - serde_json::to_vec_pretty(&record).expect("serialize metadata"), - ) - .expect("age out cached bytes"); - } -} - -/// A pinned commit is content-addressed, so its archive can never change -/// meaning and age carries no information about it. Reuse is guarded by the -/// per-load re-hash, not by a clock. Expiring such an entry would buy nothing -/// and would stop a pinned, reproducible project from typechecking offline or -/// while GitHub is rate-limiting ([STUBRES-TYPESHED-ACQUIRE]). -#[test] -fn aged_out_cached_bytes_are_still_reused_for_an_exact_pin() { - let cache_dir = tempfile::tempdir().expect("cache dir"); - let cache = DiskCache::new(cache_dir.path()); - let a = fixture(A_SHA, "A"); - let exact = SourceSelection::ExactCommit { - commit: a.metadata.commit, - }; - let seed = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let _ = manager(request(exact.clone(), true), seed, Some(cache.clone())) - .snapshot() - .expect("seed exact cache"); - age_out_cached_bytes(cache_dir.path(), A_SHA); - - let offline = Arc::new(FakeTransport::offline()); - let snapshot = manager(request(exact, true), Arc::clone(&offline), Some(cache)) - .snapshot() - .expect("an aged-out exact pin still activates offline"); - assert_eq!(snapshot.status.commit, Some(a.metadata.commit)); - assert!(matches!( - snapshot.identity, - SourceIdentity::Commit { pinned: true, .. } - )); - assert_eq!( - offline.commit_calls.load(Ordering::SeqCst), - 0, - "an immutable pin must not be re-resolved because its bytes aged" - ); - assert_eq!(offline.tree_calls.load(Ordering::SeqCst), 0); - assert_eq!(offline.archive_calls.load(Ordering::SeqCst), 0); -} - -/// The counterpart to the pin exemption: `Latest` tracks the moving `main` -/// reference, so aged bytes really could misrepresent the selection and the -/// 24-hour window still applies. Dropping expiry wholesale would silently -/// pin users to whatever `main` was the day they first ran. -#[test] -fn expired_cached_bytes_are_reacquired_for_the_moving_latest_reference() { - let cache_dir = tempfile::tempdir().expect("cache dir"); - let cache = DiskCache::new(cache_dir.path()); - let a = fixture(A_SHA, "A"); - let seed = Arc::new(FakeTransport::new( - Some(a.metadata.commit), - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let _ = manager( - request(SourceSelection::Latest, true), - seed, - Some(cache.clone()), - ) - .snapshot() - .expect("seed latest cache"); - age_out_cached_bytes(cache_dir.path(), A_SHA); - - let retry = Arc::new(FakeTransport::new( - Some(a.metadata.commit), - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let snapshot = manager( - request(SourceSelection::Latest, true), - Arc::clone(&retry), - Some(cache), - ) - .snapshot() - .expect("reacquire expired latest bytes"); - assert_eq!(snapshot.status.commit, Some(a.metadata.commit)); - assert_eq!(retry.latest_calls.load(Ordering::SeqCst), 1); - assert_eq!( - retry.archive_calls.load(Ordering::SeqCst), - 1, - "expired bytes for a moving reference must be redownloaded" - ); -} - -#[test] -fn exact_bundle_commit_restarts_offline_without_a_download_cache() { - let commit = oid(bundled_commit_sha()); - let offline = Arc::new(FakeTransport::offline()); - let snapshot = manager( - request(SourceSelection::ExactCommit { commit }, true), - Arc::clone(&offline), - None, - ) - .snapshot() - .expect("the embedded ZIP is the exact pinned source after restart"); - assert_eq!(snapshot.status.active_source, SourceKind::Bundled); - assert_eq!(snapshot.status.commit, Some(commit)); +fn an_explicit_pin_of_the_bundled_commit_suppresses_unpinned() { + let store = tempfile::tempdir().expect("tempdir"); + let commit = Oid::from_hex(super::super::bundle::bundled_commit_sha()).expect("bundled sha"); + let manager = production_manager(pinned_request(commit, true, store.path())); + let snapshot = manager.snapshot().expect("explicit bundled pin resolves"); assert!(snapshot.status.warnings.is_empty()); - // The bundle satisfies its own pin before any acquisition, so an offline - // restart makes no transport call at all ([STUBRES-TYPESHED-ACQUIRE]). - assert_eq!(offline.commit_calls.load(Ordering::SeqCst), 0); - assert_eq!(offline.archive_calls.load(Ordering::SeqCst), 0); + assert_eq!(snapshot.status.active_source, SourceKind::Bundled); } +/// Implements the **Fails hard** acceptance item: a pin with no store entry +/// refuses to analyse, names the missing SHA and its fix, and never +/// substitutes another source ([STUBRES-TYPESHED-OFFLINE]). #[test] -fn cache_off_writes_nothing_and_eviction_redownloads_the_same_pin() { - let cache_dir = tempfile::tempdir().expect("cache dir"); - let cache = DiskCache::new(cache_dir.path()); - let a = fixture(A_SHA, "A"); - let exact = SourceSelection::ExactCommit { - commit: a.metadata.commit, - }; - - let mut no_cache_request = request(exact.clone(), true); - no_cache_request.use_cache = false; - let no_cache_transport = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let _ = manager(no_cache_request, no_cache_transport, Some(cache.clone())) - .snapshot() - .expect("cache-off acquisition"); +fn a_pin_absent_from_this_machine_is_terminal_no_source() { + let store = tempfile::tempdir().expect("tempdir"); + let commit = Oid::from_hex(OTHER_SHA).expect("valid oid"); + let manager = production_manager(pinned_request(commit, true, store.path())); + let error = manager.snapshot().expect_err("missing pin must fail"); assert_eq!( - std::fs::read_dir(cache_dir.path()) - .expect("cache root") - .count(), - 0, - "cache-off must validate and discard without a generation directory" + error, + SelectionError::NoSource { + commit, + reason: BackendError::Missing, + } ); - - let seed = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let _ = manager(request(exact.clone(), true), seed, Some(cache.clone())) - .snapshot() - .expect("seed exact cache"); - std::fs::remove_dir_all(cache_dir.path().join(A_SHA)).expect("explicit cache eviction"); - - let retry = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let snapshot = manager(request(exact, true), Arc::clone(&retry), Some(cache)) - .snapshot() - .expect("same pin reacquired"); - assert_eq!(snapshot.status.commit, Some(a.metadata.commit)); - assert_eq!(retry.commit_calls.load(Ordering::SeqCst), 1); - assert_eq!(retry.archive_calls.load(Ordering::SeqCst), 1); -} - -#[test] -fn license_drift_blocks_exact_and_mirror_and_latest_falls_back_loudly() { - let drifted = fixture_with_license(A_SHA, "DRIFT", b"changed license identity\n".to_vec()); - let exact = SourceSelection::ExactCommit { - commit: drifted.metadata.commit, - }; - - for origin in [SourceTransport::Codeload, SourceTransport::Mirror] { - let transport = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&drifted), - origin, - )); - let error = manager(request(exact.clone(), true), transport, None) - .snapshot() - .expect_err("license drift must fail an exact source"); - assert!(matches!( - error, - super::super::selector::SelectionError::Exact { - reason: BackendError::LicenseChanged, - .. - } - )); - } - - let latest_transport = Arc::new(FakeTransport::new( - Some(drifted.metadata.commit), - std::slice::from_ref(&drifted), - SourceTransport::Codeload, - )); - let fallback = manager( - request(SourceSelection::Latest, true), - latest_transport, - None, - ) - .snapshot() - .expect("Latest may use only the vetted bundle after drift"); - assert_eq!(fallback.status.active_source, SourceKind::Bundled); + assert!(error.to_string().contains(OTHER_SHA)); + assert!(error.to_string().contains("Download latest")); + // The store is inert: the failed read created nothing. assert_eq!( - fallback - .status - .warnings - .iter() - .map(|warning| warning.code.as_str()) - .collect::>(), - vec!["UNPINNED", "DOWNLOAD FAILED", "LICENSE CHANGED"] + fs::read_dir(store.path()).expect("readdir").count(), + 0, + "resolution must never write the store" ); } +/// A present-but-corrupt entry is the same terminal failure with its reason. #[test] -fn cache_mutation_is_rejected_and_reacquired() { - let cache_dir = tempfile::tempdir().expect("cache dir"); - let cache = DiskCache::new(cache_dir.path()); - let a = fixture(A_SHA, "A"); - let online = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let exact = SourceSelection::ExactCommit { - commit: a.metadata.commit, - }; - let _ = manager(request(exact.clone(), true), online, Some(cache.clone())) - .snapshot() - .expect("seed cache"); - let cached_zip = cache_dir - .path() - .join(A_SHA) - .join("generations") - .join(sha256_hex(&a.zip)) - .join("archive.zip"); - std::fs::write(cached_zip, b"mutated").expect("mutate cache"); - let retry = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let transport: Arc = Arc::::clone(&retry); - let backend = RuntimeBackend::new(transport, Some(cache)); - let snapshot = backend - .load_commit(a.metadata.commit, &request(exact, true)) - .expect("reacquired valid archive"); +fn a_corrupt_store_entry_is_terminal_no_source() { + let store = tempfile::tempdir().expect("tempdir"); + let commit = Oid::from_hex(OTHER_SHA).expect("valid oid"); + let dir = store.path().join(OTHER_SHA); + fs::create_dir_all(&dir).expect("entry dir"); + fs::write(dir.join("commit-object"), b"not a commit object").expect("garbage"); + let manager = production_manager(pinned_request(commit, true, store.path())); assert_eq!( - snapshot.read_stub("sentinel").map(|(_, body)| body), - Some("VALUE: str # A\n") + manager.snapshot().err(), + Some(SelectionError::NoSource { + commit, + reason: BackendError::Corrupt, + }) ); - assert_eq!(retry.archive_calls.load(Ordering::SeqCst), 1); + // The checker never repairs or evicts: the corrupt entry stays on disk + // until a download replaces it ([STUBRES-TYPESHED-STORE]). + assert!(dir.join("commit-object").exists()); } +/// A missing custom folder is the custom source's own hard failure; a custom +/// module miss (folder exists, module absent) is step-4 fallthrough and is +/// covered by the resolver tests, not a source substitution. #[test] -fn verification_on_rehashes_cached_archive_offline() { - let cache_dir = tempfile::tempdir().expect("cache dir"); - let cache = DiskCache::new(cache_dir.path()); - let a = fixture(A_SHA, "A"); - let online = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let exact = SourceSelection::ExactCommit { - commit: a.metadata.commit, - }; - let unverified = manager(request(exact.clone(), false), online, Some(cache.clone())) - .snapshot() - .expect("unverified"); - assert_eq!(unverified.status.provenance, Provenance::Unverified); - assert!(unverified.status.tree.is_none()); - let offline = Arc::new(FakeTransport::offline()); - let verified_snapshot = manager(request(exact, true), Arc::clone(&offline), Some(cache)) - .snapshot() - .expect("verify cached bytes"); +fn a_missing_custom_folder_fails_without_fallback() { + let manager = production_manager(TypeshedRequest { + selection: SourceSelection::Custom { + path: "/nonexistent/custom-typeshed".to_owned(), + }, + store_path: None, + }); assert_eq!( - verified_snapshot.status.provenance, - Provenance::GithubTlsAttested + manager.snapshot().err(), + Some(SelectionError::Custom(BackendError::Custom)) ); - assert_eq!(verified_snapshot.status.tree, Some(a.metadata.tree)); - assert_eq!(offline.commit_calls.load(Ordering::SeqCst), 0); - assert_eq!(offline.archive_calls.load(Ordering::SeqCst), 0); } +/// A real custom tree activates verbatim and reports user-managed status. #[test] -fn cache_preserves_mirror_origin_across_configuration_changes() { - let cache_dir = tempfile::tempdir().expect("cache dir"); - let cache = DiskCache::new(cache_dir.path()); - let a = fixture(A_SHA, "A"); - let mirror = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&a), - SourceTransport::Mirror, - )); - let exact = SourceSelection::ExactCommit { - commit: a.metadata.commit, - }; - let _ = manager(request(exact.clone(), true), mirror, Some(cache.clone())) - .snapshot() - .expect("mirror"); - let authenticated = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let reused = manager(request(exact, true), authenticated, Some(cache)) - .snapshot() - .expect("cached mirror"); - assert_eq!(reused.status.transport, SourceTransport::Mirror); -} - -#[test] -fn trusted_git_modes_override_zip_modes_and_blob_mutation_fails() { - let license = bundled_snapshot() - .expect("bundle") - .vfs - .read("LICENSE") - .expect("license") - .to_vec(); - let files = vec![ - ("LICENSE".to_owned(), license, FileMode::Regular, 0o644), - ( - "stdlib/VERSIONS".to_owned(), - b"sentinel: 3.0-\n".to_vec(), - FileMode::Regular, - 0o644, - ), - ( - "stdlib/sentinel.pyi".to_owned(), - b"VALUE: str\n".to_vec(), - FileMode::Executable, - 0o644, - ), - ]; - let valid = make_fixture(A_SHA, files); - let fake = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&valid), - SourceTransport::Codeload, - )); - let cache_dir = tempfile::tempdir().expect("cache dir"); - let cache = DiskCache::new(cache_dir.path()); - let cached_request = request( - SourceSelection::ExactCommit { - commit: valid.metadata.commit, +fn a_custom_tree_activates_verbatim() { + let root = tempfile::tempdir().expect("tempdir"); + let stdlib = root.path().join("stdlib"); + fs::create_dir(&stdlib).expect("stdlib"); + fs::write(stdlib.join("VERSIONS"), "os: 3.0-\n").expect("versions"); + fs::write(stdlib.join("os.pyi"), "name: str\n").expect("stub"); + let manager = production_manager(TypeshedRequest { + selection: SourceSelection::Custom { + path: root.path().to_string_lossy().into_owned(), }, - true, - ); - let backend = RuntimeBackend::new(fake, Some(cache.clone())); - assert!(backend - .load_commit(valid.metadata.commit, &cached_request) - .is_ok()); - let authenticated = RuntimeBackend::new( - Arc::new(FakeTransport::new( - None, - std::slice::from_ref(&valid), - SourceTransport::Codeload, - )), - Some(cache), - ); - assert!(authenticated - .load_commit(valid.metadata.commit, &cached_request) - .is_ok()); - - let mut no_cache = cached_request; - no_cache.use_cache = false; - - let mut mutated = valid.clone(); - let needle = b"VALUE: str"; - let position = mutated - .zip - .windows(needle.len()) - .position(|window| window == needle) - .expect("stored body"); - let byte = mutated.zip.get_mut(position).expect("stored body position"); - *byte = b'X'; - let bad = RuntimeBackend::new( - Arc::new(FakeTransport::new( - None, - &[mutated], - SourceTransport::Codeload, - )), - None, - ); + store_path: None, + }); + let snapshot = manager.snapshot().expect("custom tree resolves"); + assert_eq!(snapshot.status.active_source, SourceKind::Custom); + let codes: Vec<&str> = snapshot + .status + .warnings + .iter() + .map(|warning| warning.code.as_str()) + .collect(); + assert_eq!(codes, vec!["UNPINNED", "USER-MANAGED SOURCE"]); assert_eq!( - bad.load_commit(valid.metadata.commit, &no_cache).err(), - Some(BackendError::Validation) + snapshot.read_stub("os").map(|(_, body)| body), + Some("name: str\n") ); } -// `typeshed-cache-path` selection ([STUBRES-TYPESHED-CONFIG]). The wiring in -// `production_manager` was previously unreachable from a test, because that -// function also builds an HTTPS transport; `select_cache` isolates the choice. - -/// A configured `typeshed-cache-path` must be the directory that actually -/// receives cached bytes. Asserting the cache is merely `Some` would pass even -/// if the configured path were dropped and the per-user OS cache silently used -/// instead — which would write outside the project while appearing to work. +/// The store location is honoured: an entry in a configured store resolves, +/// and the same pin against an empty default-shaped store does not. #[test] -fn configured_cache_path_is_the_directory_actually_written_to() { - let dir = tempfile::tempdir().expect("cache dir"); - let cache = select_cache(true, Some(dir.path().to_path_buf())).expect("cache enabled"); - - let zip = b"not-a-real-zip-but-hashed-consistently"; - let key = CacheKey::from_identity(A_SHA); - let record = CacheRecord { - commit: Some(A_SHA.to_owned()), - tree: None, - zip_sha256: sha256_hex(zip), - verified: false, - transport: None, - acquired_at_unix_seconds: 1, - tree_files: Vec::new(), - }; - cache.store(&key, zip, &record).expect("store into cache"); - - let generation = dir - .path() - .join(key.dir_name()) - .join("generations") - .join(&record.zip_sha256); - assert!( - generation.is_dir(), - "cached bytes must land under the configured typeshed-cache-path, \ - found nothing at {}", - generation.display() +fn pins_resolve_from_the_configured_store_root() { + let configured = tempfile::tempdir().expect("tempdir"); + let elsewhere = tempfile::tempdir().expect("tempdir"); + let commit = Oid::from_hex(OTHER_SHA).expect("valid oid"); + let backend = RuntimeBackend::new(Some(configured.path().to_path_buf())); + assert_eq!( + backend.load_pinned(commit, true).err(), + Some(BackendError::Missing) ); -} - -/// `typeshed-cache = false` outranks a configured path. Were it not to, a -/// project that switched caching off would keep reusing archives from the very -/// directory it also named, and the setting would be a no-op. -#[test] -fn disabling_the_cache_outranks_a_configured_cache_path() { - let dir = tempfile::tempdir().expect("cache dir"); - assert!(select_cache(false, Some(dir.path().to_path_buf())).is_none()); - assert!(select_cache(false, None).is_none()); -} - -/// With caching on and no path configured, selection falls back to the -/// canonical per-user OS cache rather than disabling reuse. Compared against -/// `default_cache_path` so the test states the same thing on a platform that -/// exposes no user cache directory, without writing to the real one. -#[test] -fn caching_without_a_configured_path_falls_back_to_the_os_cache() { + let other_backend = RuntimeBackend::new(Some(elsewhere.path().to_path_buf())); assert_eq!( - select_cache(true, None).is_some(), - default_cache_path().is_some(), - "an unconfigured cache must fall back to the per-user OS cache" + other_backend.load_pinned(commit, true).err(), + Some(BackendError::Missing) ); } diff --git a/crates/basilisk-stubs/src/typeshed/runtime/tests/integrity.rs b/crates/basilisk-stubs/src/typeshed/runtime/tests/integrity.rs deleted file mode 100644 index 676411cb..00000000 --- a/crates/basilisk-stubs/src/typeshed/runtime/tests/integrity.rs +++ /dev/null @@ -1,153 +0,0 @@ -use super::*; - -fn approved_files() -> Vec<(String, Vec, FileMode, u32)> { - let license = bundled_snapshot() - .expect("bundle") - .vfs - .read("LICENSE") - .expect("bundle license") - .to_vec(); - vec![ - ("LICENSE".to_owned(), license, FileMode::Regular, 0o644), - ( - "stdlib/VERSIONS".to_owned(), - b"sentinel: 3.0-\n".to_vec(), - FileMode::Regular, - 0o644, - ), - ( - "stdlib/sentinel.pyi".to_owned(), - b"VALUE: str\n".to_vec(), - FileMode::Regular, - 0o644, - ), - ] -} - -#[test] -fn archive_encoding_is_not_the_tree_identity_and_pin_alone_is_not_attestation() { - let stored = make_fixture_with_compression(A_SHA, approved_files(), CompressionMethod::Stored); - let deflated = - make_fixture_with_compression(A_SHA, approved_files(), CompressionMethod::Deflated); - assert_ne!(stored.zip, deflated.zip, "ZIP encodings must differ"); - assert_eq!(stored.metadata.tree, deflated.metadata.tree); - - for fixture in [&stored, &deflated] { - let transport = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(fixture), - SourceTransport::Codeload, - )); - let snapshot = manager( - request( - SourceSelection::ExactCommit { - commit: fixture.metadata.commit, - }, - true, - ), - transport, - None, - ) - .snapshot() - .expect("tree-attested archive"); - assert_eq!(snapshot.status.tree, Some(fixture.metadata.tree)); - assert_eq!(snapshot.status.provenance, Provenance::GithubTlsAttested); - assert!(!snapshot.status.signed_release); - assert_eq!( - snapshot.read_stub("sentinel").map(|(_, body)| body), - Some("VALUE: str\n") - ); - } - - let mut pin_without_metadata = FakeTransport::new( - None, - std::slice::from_ref(&stored), - SourceTransport::Codeload, - ); - pin_without_metadata.commits.clear(); - pin_without_metadata.trees.clear(); - let transport = Arc::new(pin_without_metadata); - let error = manager( - request( - SourceSelection::ExactCommit { - commit: stored.metadata.commit, - }, - true, - ), - Arc::clone(&transport), - None, - ) - .snapshot() - .expect_err("a commit pin alone cannot authenticate an archive tree"); - assert!(matches!( - error, - super::super::super::selector::SelectionError::Exact { - reason: BackendError::Metadata, - .. - } - )); - assert_eq!(transport.archive_calls.load(Ordering::SeqCst), 0); -} - -#[test] -fn root_and_nested_legal_drift_have_identical_exact_and_latest_policy() { - let root_drift = fixture_with_license( - A_SHA, - "ROOT-DRIFT", - b"changed root license identity\n".to_vec(), - ); - let mut nested_files = approved_files(); - nested_files.push(( - "stdlib/NOTICE.runtime".to_owned(), - b"new nested legal identity\n".to_vec(), - FileMode::Regular, - 0o644, - )); - let nested_drift = make_fixture(A_SHA, nested_files); - - for drifted in [&root_drift, &nested_drift] { - let exact = SourceSelection::ExactCommit { - commit: drifted.metadata.commit, - }; - for origin in [SourceTransport::Codeload, SourceTransport::Mirror] { - let exact_transport = Arc::new(FakeTransport::new( - None, - std::slice::from_ref(drifted), - origin, - )); - let exact_error = manager(request(exact.clone(), true), exact_transport, None) - .snapshot() - .expect_err("legal drift must fail an exact source"); - assert!(matches!( - exact_error, - super::super::super::selector::SelectionError::Exact { - reason: BackendError::LicenseChanged, - .. - } - )); - - let latest_transport = Arc::new(FakeTransport::new( - Some(drifted.metadata.commit), - std::slice::from_ref(drifted), - origin, - )); - let fallback = manager( - request(SourceSelection::Latest, true), - latest_transport, - None, - ) - .snapshot() - .expect("Latest may only fall back to the reviewed bundle"); - assert_eq!(fallback.status.active_source, SourceKind::Bundled); - assert_eq!( - fallback - .status - .warnings - .iter() - .map(|warning| warning.code.as_str()) - .collect::>(), - vec!["UNPINNED", "DOWNLOAD FAILED", "LICENSE CHANGED"] - ); - } - } -} diff --git a/crates/basilisk-stubs/src/typeshed/selector.rs b/crates/basilisk-stubs/src/typeshed/selector.rs index ac335645..4a48fb39 100644 --- a/crates/basilisk-stubs/src/typeshed/selector.rs +++ b/crates/basilisk-stubs/src/typeshed/selector.rs @@ -1,42 +1,35 @@ //! Implements [STUBRES-TYPESHED] source selection. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED //! -//! The policy layer is deliberately separate from HTTP/cache mechanics. A -//! backend can fetch and gate candidates, while this module enforces the three -//! non-negotiable failure rules: Custom never falls back, Exact only accepts -//! the requested commit (served from a matching bundle before any network -//! acquisition), and Latest falls back to the bundle rather than reusing an -//! older unpinned cache entry. +//! The policy layer over local source backends. There are exactly two sources +//! and both fail closed: a custom folder never falls back, and a pinned commit +//! is served from the embedded bundle (when it IS that commit) or from the +//! local store — never from the network, which this crate cannot even reach +//! ([STUBRES-TYPESHED-OFFLINE]). A pin that is not on this machine is the +//! terminal `NO SOURCE` failure and analysis does not run. use super::gittree::Oid; use super::snapshot::Snapshot; use super::source::{ - LicenseStatus, Provenance, SourceIdentity, SourceKind, SourceSelection, StatusWarning, - Transport, TypeshedRequest, + LicenseStatus, SourceIdentity, SourceKind, SourceSelection, StatusWarning, TypeshedRequest, }; use super::warning::{TypeshedWarning, UnpinnedKind}; /// A redacted backend failure category. /// -/// No variant carries an arbitrary URL or transport error string. This error -/// is safe to expose through MCP/LSP; detailed adapter errors belong in -/// redacted tracing at the transport boundary. +/// No variant carries a path or adapter detail. This error is safe to expose +/// through MCP/LSP; detailed errors belong in redacted tracing at the backend +/// boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum BackendError { /// The configured source was malformed or unusable. #[error("invalid typeshed source configuration")] InvalidConfiguration, - /// Official `main` or commit metadata could not be resolved. - #[error("typeshed metadata resolution failed")] - Metadata, - /// Archive download failed. - #[error("typeshed archive download failed")] - Download, - /// Cached bytes or cache I/O failed. - #[error("typeshed cache operation failed")] - Cache, - /// Safety, shape, or content verification rejected the candidate. - #[error("typeshed archive validation failed")] - Validation, + /// The pinned commit has no entry in the local store. + #[error("commit is not in the local store")] + Missing, + /// A store entry exists but failed offline pin verification. + #[error("store entry failed offline verification")] + Corrupt, /// The approved legal-file identity drifted. #[error("typeshed license identity changed")] LicenseChanged, @@ -48,13 +41,12 @@ pub enum BackendError { Bundle, } -/// Transport/cache adapter consumed by the policy selector. +/// Local source adapter consumed by the policy selector. /// -/// Implementations must return only fully gated, immutable [`Snapshot`]s. In -/// particular, `load_latest` resolves `main` once and must never substitute an -/// older cached commit; only [`select_snapshot`] is allowed to choose a bundle -/// after that operation fails. -pub trait AcquisitionBackend: Send + Sync { +/// Implementations must return only fully gated, immutable [`Snapshot`]s read +/// from this machine — there is no network seam to implement +/// ([STUBRES-TYPESHED-OFFLINE]). +pub trait SourceBackend: Send + Sync { /// Load and validate a user-managed custom tree. /// /// # Errors @@ -62,20 +54,13 @@ pub trait AcquisitionBackend: Send + Sync { /// Returns a redacted failure when the tree cannot become a snapshot. fn load_custom(&self, path: &str) -> Result; - /// Load and validate one exact commit from cache or transport. + /// Load and offline-verify one pinned commit from the local store + /// ([STUBRES-TYPESHED-PIN]). /// /// # Errors /// - /// Returns a redacted failure when the selected commit cannot activate. - fn load_commit(&self, commit: Oid, request: &TypeshedRequest) - -> Result; - - /// Resolve `main` once, then load and validate that exact resolved commit. - /// - /// # Errors - /// - /// Returns a redacted failure when metadata or candidate activation fails. - fn load_latest(&self, request: &TypeshedRequest) -> Result; + /// Returns a redacted failure when no verified entry exists. + fn load_pinned(&self, commit: Oid, explicit: bool) -> Result; /// Load and validate the embedded offline bundle. /// @@ -85,50 +70,69 @@ pub trait AcquisitionBackend: Send + Sync { fn load_bundled(&self) -> Result; } -/// A terminal source-selection failure. +/// A terminal source-selection failure. Analysis does not run +/// ([STUBRES-TYPESHED-OFFLINE]): there is no substitute source and no +/// degraded untyped mode. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum SelectionError { /// Custom is the sole step-3 source and could not activate. #[error("custom typeshed failed without fallback: {0}")] Custom(BackendError), - /// Neither the exact commit nor an equal bundled commit was available. - #[error("exact typeshed commit {commit} is unavailable: {reason}")] - Exact { - /// The full requested commit SHA. + /// The pinned commit is not on this machine, or the entry that IS on this + /// machine failed a gate. The message is the matching spec status line + /// ([STUBRES-TYPESHED-WARN]) — the two persistent statuses stay distinct, + /// see [`terminal_status_line`]. + #[error("{}", terminal_status_line(.commit, *.reason))] + NoSource { + /// The full pinned commit SHA. commit: Oid, - /// The redacted primary failure. + /// The redacted category (missing, corrupt, license drift…). reason: BackendError, }, - /// Latest and the offline bundle both failed. - #[error("latest typeshed and bundled fallback are unavailable ({latest}; {bundle})")] - LatestAndBundle { - /// The redacted Latest failure. - latest: BackendError, - /// The redacted bundle failure. - bundle: BackendError, - }, /// A backend returned a source different from the requested candidate. #[error("typeshed backend returned an inconsistent source identity")] InconsistentIdentity, } +/// The spec's persistent status line for a terminal pinned-source failure +/// ([STUBRES-TYPESHED-WARN] lists the two as separate rows). Drift of the +/// build-approved license identity is its own status: those bytes ARE on this +/// machine and downloading them again changes nothing, so it must never +/// masquerade as `NO SOURCE`. The full SHA rides along either way — every +/// surface shows it when it is known. +fn terminal_status_line(commit: &Oid, reason: BackendError) -> String { + match reason { + BackendError::LicenseChanged => format!( + "{} (commit {commit})", + TypeshedWarning::LicenseChanged.message() + ), + BackendError::InvalidConfiguration + | BackendError::Missing + | BackendError::Corrupt + | BackendError::Custom + | BackendError::Bundle => format!( + "NO SOURCE — {commit} is not on this machine; run Download latest or basilisk typeshed download --commit {commit}" + ), + } +} + /// Select exactly one complete step-3 source under the configured policy. /// /// # Errors /// -/// Returns a redacted [`SelectionError`] when no eligible source activates. +/// Returns a redacted [`SelectionError`] when the selected source does not +/// activate; nothing is ever substituted for it. pub fn select_snapshot( request: &TypeshedRequest, - backend: &dyn AcquisitionBackend, + backend: &dyn SourceBackend, ) -> Result { match &request.selection { SourceSelection::Custom { path } => select_custom(path, backend), - SourceSelection::ExactCommit { commit } => select_exact(*commit, request, backend), - SourceSelection::Latest => select_latest(request, backend), + SourceSelection::Pinned { commit, explicit } => select_pinned(*commit, *explicit, backend), } } -fn select_custom(path: &str, backend: &dyn AcquisitionBackend) -> Result { +fn select_custom(path: &str, backend: &dyn SourceBackend) -> Result { let mut snapshot = backend.load_custom(path).map_err(SelectionError::Custom)?; if !matches!(&snapshot.identity, SourceIdentity::Custom { .. }) || !identity_matches_vfs(&snapshot) @@ -138,10 +142,8 @@ fn select_custom(path: &str, backend: &dyn AcquisitionBackend) -> Result Result Result { // A pin naming the bundled commit is complete inside the binary: the // commit is content-addressed, so the embedded bytes ARE the pinned - // source. Consulting the network first would only add failure modes and - // spend rate-limited metadata calls ([STUBRES-TYPESHED-ACQUIRE]). The - // SHA guard keeps every other pin from paying the embedded-ZIP decode - // and gate run for a bundle that cannot satisfy it. + // source, and no store lookup is needed ([STUBRES-TYPESHED]). if commit.to_hex() == super::bundle::bundled_commit_sha() { - if let Some(snapshot) = backend + return backend .load_bundled() - .ok() - .and_then(|bundle| pinned_bundle(bundle, commit)) - { - return Ok(snapshot); - } + .map_err(|reason| SelectionError::NoSource { commit, reason }) + .and_then(|bundle| pinned_bundle(bundle, commit, explicit)); } - match backend.load_commit(commit, request) { - Ok(mut snapshot) => { + match backend.load_pinned(commit, explicit) { + Ok(snapshot) => { if !matches!( &snapshot.identity, - SourceIdentity::Commit { commit: actual, .. } if *actual == commit + SourceIdentity::Commit { commit: actual, pinned } if *actual == commit && *pinned == explicit ) || !identity_matches_vfs(&snapshot) + || snapshot.status.active_source != SourceKind::ExactCommit + || snapshot.status.commit != Some(commit) { return Err(SelectionError::InconsistentIdentity); } - snapshot.status.active_source = SourceKind::ExactCommit; - snapshot.status.commit = Some(commit); - normalize_download_verification(&mut snapshot, request.verify_content, &[])?; Ok(snapshot) } - // The bundle was already offered above and cannot satisfy this pin, - // so a failed download is terminal: Exact fails closed. - Err(reason) => Err(SelectionError::Exact { commit, reason }), + // The bundle cannot satisfy this pin and there is no network to reach: + // the pin fails closed as NO SOURCE ([STUBRES-TYPESHED-OFFLINE]). + Err(reason) => Err(SelectionError::NoSource { commit, reason }), } } -/// The embedded bundle reclassified as the user's pin — only when it is -/// exactly the pinned commit and passes identity and provenance checks. The -/// user explicitly pinned this exact commit, so a matching embedded bundle is -/// deterministic and therefore suppresses UNPINNED. -fn pinned_bundle(mut bundle: Snapshot, commit: Oid) -> Option { +/// The embedded bundle serving a pin of exactly its own commit. An explicit +/// pin is deterministic and suppresses `UNPINNED`; the bundled default keeps +/// it ([STUBRES-TYPESHED-WARN]). +fn pinned_bundle( + mut bundle: Snapshot, + commit: Oid, + explicit: bool, +) -> Result { if !matches!(&bundle.identity, SourceIdentity::Bundled { commit: actual } if *actual == commit) || !identity_matches_vfs(&bundle) - || bundle.status.provenance != Provenance::BundleVetted { - return None; + return Err(SelectionError::InconsistentIdentity); } bundle.status.active_source = SourceKind::Bundled; bundle.status.commit = Some(commit); - bundle.status.transport = Transport::EmbeddedZip; - set_warnings(&mut bundle, &[]); - Some(bundle) -} - -fn select_latest( - request: &TypeshedRequest, - backend: &dyn AcquisitionBackend, -) -> Result { - match backend.load_latest(request) { - Ok(mut snapshot) => { - if !matches!(&snapshot.identity, SourceIdentity::Commit { .. }) - || !identity_matches_vfs(&snapshot) - { - return Err(SelectionError::InconsistentIdentity); - } - snapshot.status.active_source = SourceKind::Latest; - snapshot.status.commit = snapshot.identity.commit(); - normalize_download_verification( - &mut snapshot, - request.verify_content, - &[TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled)], - )?; - Ok(snapshot) - } - Err(latest) => { - let mut bundle = backend - .load_bundled() - .map_err(|bundle| SelectionError::LatestAndBundle { latest, bundle })?; - let Some(commit) = bundle.identity.commit() else { - return Err(SelectionError::InconsistentIdentity); - }; - if !matches!(&bundle.identity, SourceIdentity::Bundled { .. }) - || !identity_matches_vfs(&bundle) - || bundle.status.provenance != Provenance::BundleVetted - { - return Err(SelectionError::InconsistentIdentity); - } - let mut warnings = vec![ - TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled), - TypeshedWarning::DownloadFailed { - bundled_sha: commit.to_hex(), - }, - ]; - if latest == BackendError::LicenseChanged { - warnings.push(TypeshedWarning::LicenseChanged); - } - bundle.status.active_source = SourceKind::Bundled; - bundle.status.commit = Some(commit); - bundle.status.transport = Transport::EmbeddedZip; - set_warnings(&mut bundle, &warnings); - Ok(bundle) - } - } -} - -fn normalize_download_verification( - snapshot: &mut Snapshot, - verify_content: bool, - base: &[TypeshedWarning], -) -> Result<(), SelectionError> { - let mut warnings = base.to_vec(); - if verify_content { - // Policy must never manufacture attestation from a request flag. The - // backend earns this state only after trusted commit→tree metadata and - // the Content gate bind the exact VFS bytes to that tree. - if snapshot.status.provenance != Provenance::GithubTlsAttested - || snapshot.status.tree.is_none() - { - return Err(SelectionError::InconsistentIdentity); - } + let warnings: &[TypeshedWarning] = if explicit { + &[] } else { - snapshot.status.tree = None; - snapshot.status.provenance = Provenance::Unverified; - warnings.push(TypeshedWarning::Unverified); - } - set_warnings(snapshot, &warnings); - Ok(()) + &[TypeshedWarning::Unpinned(UnpinnedKind::BundledDefault)] + }; + set_warnings(&mut bundle, warnings); + Ok(bundle) } fn set_warnings(snapshot: &mut Snapshot, warnings: &[TypeshedWarning]) { @@ -299,3 +225,50 @@ fn identity_matches_vfs(snapshot: &Snapshot) -> bool { reason = "test-only fixtures use fixed embedded assets and SHA constants" )] mod tests; + +/// [STUBRES-TYPESHED-WARN]: the status table's two terminal rows — license +/// drift and an absent/unverifiable pin — never collapse into one message. +#[cfg(test)] +mod status_line_tests { + use super::{terminal_status_line, BackendError, Oid, SelectionError}; + + const SHA: &str = "0123456789012345678901234567890123456789"; + + #[test] + fn license_drift_reports_its_own_status_line_never_no_source() { + let rendered = Oid::from_hex(SHA).map(|commit| { + SelectionError::NoSource { + commit, + reason: BackendError::LicenseChanged, + } + .to_string() + }); + assert_eq!( + rendered.ok(), + Some(format!( + "LICENSE CHANGED — Basilisk update/review required (commit {SHA})" + )), + "license drift is the spec's LICENSE CHANGED status, not NO SOURCE" + ); + } + + #[test] + fn every_other_category_reports_the_no_source_recovery_line() { + for reason in [ + BackendError::Missing, + BackendError::Corrupt, + BackendError::InvalidConfiguration, + BackendError::Custom, + BackendError::Bundle, + ] { + let rendered = Oid::from_hex(SHA).map(|commit| terminal_status_line(&commit, reason)); + assert_eq!( + rendered.ok(), + Some(format!( + "NO SOURCE — {SHA} is not on this machine; run Download latest or basilisk typeshed download --commit {SHA}" + )), + "{reason:?} must keep the loud NO SOURCE recovery line" + ); + } + } +} diff --git a/crates/basilisk-stubs/src/typeshed/selector/tests.rs b/crates/basilisk-stubs/src/typeshed/selector/tests.rs index c50c7099..17f4a173 100644 --- a/crates/basilisk-stubs/src/typeshed/selector/tests.rs +++ b/crates/basilisk-stubs/src/typeshed/selector/tests.rs @@ -10,8 +10,7 @@ const BUNDLE_SHA: &str = "83c2518a9e6abbda0c44592c3483de459198f887"; #[derive(Default)] struct FakeBackend { custom: Mutex>>, - commit: Mutex>>, - latest: Mutex>>, + pinned: Mutex>>, bundle: Mutex>>, calls: Mutex>, } @@ -21,9 +20,9 @@ impl FakeBackend { slot: &Mutex>>, ) -> Result { let Ok(mut value) = slot.lock() else { - return Err(BackendError::Validation); + return Err(BackendError::Corrupt); }; - value.take().unwrap_or(Err(BackendError::Validation)) + value.take().unwrap_or(Err(BackendError::Corrupt)) } fn record(&self, call: &'static str) { @@ -33,24 +32,15 @@ impl FakeBackend { } } -impl AcquisitionBackend for FakeBackend { +impl SourceBackend for FakeBackend { fn load_custom(&self, _path: &str) -> Result { self.record("custom"); Self::take(&self.custom) } - fn load_commit( - &self, - _commit: Oid, - _request: &TypeshedRequest, - ) -> Result { - self.record("commit"); - Self::take(&self.commit) - } - - fn load_latest(&self, _request: &TypeshedRequest) -> Result { - self.record("latest"); - Self::take(&self.latest) + fn load_pinned(&self, _commit: Oid, _explicit: bool) -> Result { + self.record("pinned"); + Self::take(&self.pinned) } fn load_bundled(&self) -> Result { @@ -62,34 +52,24 @@ impl AcquisitionBackend for FakeBackend { fn request(selection: SourceSelection) -> TypeshedRequest { TypeshedRequest { selection, - verify_content: true, - use_cache: true, - url_template: None, + store_path: None, } } fn bundle() -> Snapshot { let commit = Oid::from_hex(BUNDLE_SHA).expect("valid bundle oid"); - fixture_snapshot( - SourceIdentity::Bundled { commit }, - SourceKind::Bundled, - Provenance::BundleVetted, - ) + fixture_snapshot(SourceIdentity::Bundled { commit }, SourceKind::Bundled) } -fn downloaded(commit: Oid) -> Snapshot { +fn stored(commit: Oid, explicit: bool) -> Snapshot { let identity = SourceIdentity::Commit { commit, - pinned: false, + pinned: explicit, }; - fixture_snapshot(identity, SourceKind::Latest, Provenance::GithubTlsAttested) + fixture_snapshot(identity, SourceKind::ExactCommit) } -fn fixture_snapshot( - identity: SourceIdentity, - active_source: SourceKind, - provenance: Provenance, -) -> Snapshot { +fn fixture_snapshot(identity: SourceIdentity, active_source: SourceKind) -> Snapshot { let commit = identity.commit(); let archive = Archive::new(vec![ ArchiveEntry { @@ -107,18 +87,9 @@ fn fixture_snapshot( active_source, commit, tree: commit, - transport: if active_source == SourceKind::Bundled { - Transport::EmbeddedZip - } else if active_source == SourceKind::Custom { - Transport::CustomPath - } else { - Transport::Codeload - }, license_status: LicenseStatus::Approved, license_reference: commit .map(|oid| format!("https://github.com/python/typeshed/blob/{oid}/LICENSE")), - provenance, - signed_release: false, warnings: Vec::new(), }; let uri_identity = identity.uri_component(); @@ -132,7 +103,7 @@ fn fixture_snapshot( } #[test] -fn custom_failure_never_consults_bundle() { +fn custom_failure_never_consults_another_source() { let backend = FakeBackend { custom: Mutex::new(Some(Err(BackendError::Custom))), bundle: Mutex::new(Some(Ok(bundle()))), @@ -154,117 +125,166 @@ fn custom_failure_never_consults_bundle() { ); } +/// Implements [STUBRES-TYPESHED]: a pin naming the bundled commit is already +/// complete inside the binary — content-addressed identity makes the embedded +/// bytes exact — so selection activates the bundle without touching the store, +/// and an explicit pin of it suppresses UNPINNED. #[test] -fn exact_failure_accepts_only_equal_bundle_and_suppresses_unpinned() { +fn exact_pin_of_the_bundled_commit_is_served_from_the_binary() { let matching = bundle(); let commit = matching.identity.commit().expect("bundle commit"); let backend = FakeBackend { - commit: Mutex::new(Some(Err(BackendError::Download))), bundle: Mutex::new(Some(Ok(matching))), ..FakeBackend::default() }; - let selected = select_snapshot(&request(SourceSelection::ExactCommit { commit }), &backend) - .expect("matching bundle is eligible"); + let selected = select_snapshot( + &request(SourceSelection::Pinned { + commit, + explicit: true, + }), + &backend, + ) + .expect("embedded bundle satisfies its own pinned commit"); assert_eq!(selected.status.active_source, SourceKind::Bundled); + assert_eq!(selected.status.commit, Some(commit)); assert!(selected.status.warnings.is_empty()); - - let other = Oid::from_hex(OTHER_SHA).expect("valid test oid"); - let mismatch = FakeBackend { - commit: Mutex::new(Some(Err(BackendError::Download))), - bundle: Mutex::new(Some(Ok(bundle()))), - ..FakeBackend::default() - }; - assert!(matches!( - select_snapshot(&request(SourceSelection::ExactCommit { commit: other }), &mismatch), - Err(SelectionError::Exact { commit, .. }) if commit == other - )); + assert_eq!( + backend.calls.lock().ok().map(|calls| calls.clone()), + Some(vec!["bundle"]) + ); } +/// Implements [STUBRES-TYPESHED-WARN]: the bundled default (no explicit +/// `typeshed-commit`) serves the same bytes but stays UNPINNED — a build-time +/// pin is not a user pin. #[test] -fn exact_pin_of_the_bundled_commit_never_consults_the_network() { - // Implements [STUBRES-TYPESHED-ACQUIRE]: a pin naming the bundled commit - // is already complete inside the binary — content-addressed identity makes - // the embedded bytes exact — so selection must activate the bundle without - // consulting the network-backed commit loader. Reaching for rate-limited - // metadata first is what let a 403 block a root whose pinned stdlib was - // sitting embedded in the very binary that refused to activate it. +fn the_bundled_default_reports_unpinned() { let matching = bundle(); let commit = matching.identity.commit().expect("bundle commit"); let backend = FakeBackend { bundle: Mutex::new(Some(Ok(matching))), ..FakeBackend::default() }; - let selected = select_snapshot(&request(SourceSelection::ExactCommit { commit }), &backend) - .expect("embedded bundle satisfies its own pinned commit offline"); - assert_eq!(selected.status.active_source, SourceKind::Bundled); - assert_eq!(selected.status.commit, Some(commit)); - assert_eq!(selected.status.transport, Transport::EmbeddedZip); - assert!(selected.status.warnings.is_empty()); - assert_eq!( - backend.calls.lock().ok().map(|calls| calls.clone()), - Some(vec!["bundle"]) - ); + let selected = select_snapshot( + &request(SourceSelection::Pinned { + commit, + explicit: false, + }), + &backend, + ) + .expect("bundled default"); + let codes: Vec<&str> = selected + .status + .warnings + .iter() + .map(|warning| warning.code.as_str()) + .collect(); + assert_eq!(codes, vec!["UNPINNED"]); } +/// Implements [STUBRES-TYPESHED-OFFLINE]: a pin that is not on this machine is +/// terminal NO SOURCE — no bundle substitution, no network, no degraded mode. #[test] -fn exact_license_drift_survives_an_unavailable_bundle_fallback() { +fn a_missing_pin_fails_hard_with_the_no_source_message() { let commit = Oid::from_hex(OTHER_SHA).expect("valid test oid"); let backend = FakeBackend { - commit: Mutex::new(Some(Err(BackendError::LicenseChanged))), - bundle: Mutex::new(Some(Err(BackendError::Bundle))), + pinned: Mutex::new(Some(Err(BackendError::Missing))), + bundle: Mutex::new(Some(Ok(bundle()))), ..FakeBackend::default() }; + let error = select_snapshot( + &request(SourceSelection::Pinned { + commit, + explicit: true, + }), + &backend, + ) + .expect_err("missing pin must fail"); assert_eq!( - select_snapshot(&request(SourceSelection::ExactCommit { commit }), &backend).err(), - Some(SelectionError::Exact { + error, + SelectionError::NoSource { commit, - reason: BackendError::LicenseChanged, - }) + reason: BackendError::Missing, + } + ); + // The message is the spec's NO SOURCE status line verbatim, naming the fix. + let message = error.to_string(); + assert_eq!( + message, + format!( + "NO SOURCE — {OTHER_SHA} is not on this machine; run Download latest or basilisk typeshed download --commit {OTHER_SHA}" + ) + ); + // The bundle is a different commit and must never be consulted. + assert_eq!( + backend.calls.lock().ok().map(|calls| calls.clone()), + Some(vec!["pinned"]) ); } +/// A corrupt store entry (failed offline verification) is the same terminal +/// failure with its reason preserved for status classification. +#[test] +fn a_corrupt_store_entry_fails_hard_and_keeps_its_reason() { + let commit = Oid::from_hex(OTHER_SHA).expect("valid test oid"); + for reason in [BackendError::Corrupt, BackendError::LicenseChanged] { + let backend = FakeBackend { + pinned: Mutex::new(Some(Err(reason))), + ..FakeBackend::default() + }; + assert_eq!( + select_snapshot( + &request(SourceSelection::Pinned { + commit, + explicit: true, + }), + &backend, + ) + .err(), + Some(SelectionError::NoSource { commit, reason }) + ); + } +} + #[test] -fn latest_failure_uses_bundle_with_ordered_composable_warnings() { +fn a_verified_store_entry_activates_for_an_explicit_pin() { + let commit = Oid::from_hex(OTHER_SHA).expect("valid test oid"); let backend = FakeBackend { - latest: Mutex::new(Some(Err(BackendError::LicenseChanged))), - bundle: Mutex::new(Some(Ok(bundle()))), + pinned: Mutex::new(Some(Ok(stored(commit, true)))), ..FakeBackend::default() }; - let selected = - select_snapshot(&request(SourceSelection::Latest), &backend).expect("bundle fallback"); - let codes: Vec<&str> = selected - .status - .warnings - .iter() - .map(|warning| warning.code.as_str()) - .collect(); - assert_eq!( - codes, - vec!["UNPINNED", "DOWNLOAD FAILED", "LICENSE CHANGED"] - ); - assert_eq!( - backend.calls.lock().ok().map(|calls| calls.clone()), - Some(vec!["latest", "bundle"]) - ); + let selected = select_snapshot( + &request(SourceSelection::Pinned { + commit, + explicit: true, + }), + &backend, + ) + .expect("verified store entry"); + assert_eq!(selected.status.active_source, SourceKind::ExactCommit); + assert_eq!(selected.status.commit, Some(commit)); + assert!(selected.status.warnings.is_empty()); } +/// The backend must return exactly the requested identity; anything else is a +/// wiring bug and fails closed rather than mislabeling the active source. #[test] -fn verification_off_waives_only_download_content_status() { +fn an_inconsistent_backend_identity_fails_closed() { let commit = Oid::from_hex(OTHER_SHA).expect("valid test oid"); + let other = Oid::from_hex(BUNDLE_SHA).expect("valid bundle oid"); let backend = FakeBackend { - latest: Mutex::new(Some(Ok(downloaded(commit)))), + pinned: Mutex::new(Some(Ok(stored(other, true)))), ..FakeBackend::default() }; - let mut request = request(SourceSelection::Latest); - request.verify_content = false; - let selected = select_snapshot(&request, &backend).expect("latest snapshot"); - let codes: Vec<&str> = selected - .status - .warnings - .iter() - .map(|warning| warning.code.as_str()) - .collect(); - assert_eq!(codes, vec!["UNPINNED", "UNVERIFIED"]); - assert_eq!(selected.status.provenance, Provenance::Unverified); - assert!(selected.status.tree.is_none()); + assert_eq!( + select_snapshot( + &request(SourceSelection::Pinned { + commit, + explicit: true, + }), + &backend, + ) + .err(), + Some(SelectionError::InconsistentIdentity) + ); } diff --git a/crates/basilisk-stubs/src/typeshed/snapshot.rs b/crates/basilisk-stubs/src/typeshed/snapshot.rs index 734fcd8f..ae3153e9 100644 --- a/crates/basilisk-stubs/src/typeshed/snapshot.rs +++ b/crates/basilisk-stubs/src/typeshed/snapshot.rs @@ -273,9 +273,7 @@ fn is_pyi_path(path: &str) -> bool { mod tests { use super::super::archive::{Archive, ArchiveEntry}; use super::super::gittree::FileMode; - use super::super::source::{ - LicenseStatus, Provenance, SourceKind, StatusWarning, Transport, TypeshedStatus, - }; + use super::super::source::{LicenseStatus, SourceKind, StatusWarning, TypeshedStatus}; use super::*; fn reg(path: &str, data: &[u8]) -> ArchiveEntry { @@ -299,11 +297,8 @@ mod tests { active_source: SourceKind::Custom, commit: None, tree: None, - transport: Transport::CustomPath, license_status: LicenseStatus::NotSupplied, license_reference: None, - provenance: Provenance::UserManaged, - signed_release: false, warnings: StatusWarning::list(&[]), }; Snapshot::build( diff --git a/crates/basilisk-stubs/src/typeshed/source.rs b/crates/basilisk-stubs/src/typeshed/source.rs index 1f8a1c43..baac22b4 100644 --- a/crates/basilisk-stubs/src/typeshed/source.rs +++ b/crates/basilisk-stubs/src/typeshed/source.rs @@ -4,12 +4,15 @@ //! //! `basilisk-stubs` never depends on `basilisk-config`: the CLI/LSP build a //! config-free [`TypeshedRequest`] from `[tool.basilisk]` and hand it to the -//! acquisition orchestrator. The orchestrator produces a [`SourceIdentity`] +//! resolution layer, which only ever reads sources already on this machine +//! ([STUBRES-TYPESHED-OFFLINE]). Resolution produces a [`SourceIdentity`] //! (shared by status, cache fingerprinting, and the VFS) and a //! [`TypeshedStatus`] reported verbatim by every surface — CLI banner, LSP //! Service Info, and MCP. Status is **never a Python diagnostic**, so it can //! never create a conformance false positive ([STUBRES-TYPESHED-WARN]). +use std::path::PathBuf; + use serde::Serialize; use super::gittree::Oid; @@ -21,10 +24,8 @@ use super::warning::{canonicalize, TypeshedWarning, WarningSeverity}; pub enum SourceKind { /// A custom `typeshed-path` folder (user-managed). Custom, - /// An explicit `typeshed-commit` archive (reproducible). + /// An explicit `typeshed-commit` served from the local store (reproducible). ExactCommit, - /// The latest `python/typeshed@main` archive (unpinned). - Latest, /// The bundled offline snapshot (unpinned unless it equals a user pin). Bundled, } @@ -36,26 +37,11 @@ impl SourceKind { match self { Self::Custom => "custom", Self::ExactCommit => "exact-commit", - Self::Latest => "latest", Self::Bundled => "bundled", } } } -/// How the active source's bytes were obtained. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum Transport { - /// A user-managed filesystem tree selected by `typeshed-path`. - CustomPath, - /// The build-vetted ZIP embedded in the Basilisk artifact. - EmbeddedZip, - /// GitHub codeload archive over HTTPS. - Codeload, - /// A configured `typeshed-url` `{sha}` archive mirror over HTTPS. - Mirror, -} - /// The license standing of the active source ([STUBRES-TYPESHED-LICENSE]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -68,36 +54,17 @@ pub enum LicenseStatus { NotSupplied, } -/// The provenance strength behind the active source. -/// -/// Integrity (bytes match a SHA) is never authenticity (the SHA is an official -/// typeshed release): no typeshed release signature exists, so official -/// provenance ultimately rests on GitHub/TLS ([STUBRES-TYPESHED-ACQUIRE]). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum Provenance { - /// Content attested against a tree bound to a commit by GitHub/TLS metadata. - /// There is no signed typeshed release, so authenticity rests on GitHub/TLS. - GithubTlsAttested, - /// Content was not attested against the selected tree. - Unverified, - /// A build-vetted bundled snapshot (verified by embedded ZIP digest + manifest). - BundleVetted, - /// A user-managed custom source; Basilisk attests nothing about it. - UserManaged, -} - /// The immutable identity of the active step-3 source, shared by status, cache /// fingerprinting, and the VFS. Two sources are the same iff their identity is /// equal, so it is a valid cache key and a safe URI component. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] #[serde(rename_all = "kebab-case", tag = "kind")] pub enum SourceIdentity { - /// An explicit or resolved commit, identified by its full commit SHA. + /// A pinned commit, identified by its full commit SHA. Commit { /// The full 40-hex commit SHA. commit: Oid, - /// Whether the user pinned it (`true`) or it was resolved from `main`. + /// Whether the user pinned it (`true`) or it is the bundled default. pinned: bool, }, /// The bundled snapshot, identified by its build-time commit SHA. @@ -143,6 +110,10 @@ impl SourceIdentity { } /// Which source the user configured, free of any `basilisk-config` type. +/// +/// There are exactly **two** sources ([STUBRES-TYPESHED]): a pinned commit or a +/// custom folder. There is no "track latest" selection — freshness is the +/// separate, user-invoked download component ([STUBRES-TYPESHED-DOWNLOAD]). #[derive(Debug, Clone, PartialEq, Eq)] pub enum SourceSelection { /// A custom `typeshed-path` folder (resolved, absolute). @@ -150,38 +121,29 @@ pub enum SourceSelection { /// The resolved custom path. path: String, }, - /// An explicit `typeshed-commit`. - ExactCommit { - /// The pinned full commit SHA. + /// A pinned commit, verified offline ([STUBRES-TYPESHED-PIN]). + Pinned { + /// The full commit SHA. commit: Oid, + /// `true` for an explicit `typeshed-commit`; `false` when the pin is + /// the bundled default an unset key resolves to (still `UNPINNED`). + explicit: bool, }, - /// No pin — resolve and download the latest `main`. - Latest, } -/// A config-free acquisition request the CLI/LSP builds from `[tool.basilisk]`. -#[derive(Clone, PartialEq, Eq)] +/// A config-free resolution request the CLI/LSP builds from `[tool.basilisk]`. +/// +/// Everything named here is already on this machine: resolution never opens a +/// network connection ([STUBRES-TYPESHED-OFFLINE]). There are no cache-reuse, +/// expiry, or verification-waiver fields — a pin always verifies +/// ([STUBRES-TYPESHED-PIN]). +#[derive(Debug, Clone, PartialEq, Eq)] pub struct TypeshedRequest { - /// Which source to use. + /// Which source to resolve. pub selection: SourceSelection, - /// Run the Content gate (`false` waives only content attestation). - pub verify_content: bool, - /// Reuse the on-disk immutable-ZIP cache. - pub use_cache: bool, - /// A `typeshed-url` `{sha}` archive-mirror template, if configured. - pub url_template: Option, -} - -impl std::fmt::Debug for TypeshedRequest { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("TypeshedRequest") - .field("selection", &self.selection) - .field("verify_content", &self.verify_content) - .field("use_cache", &self.use_cache) - .field("mirror_configured", &self.url_template.is_some()) - .finish() - } + /// The content-addressed store to resolve pins from + /// ([STUBRES-TYPESHED-STORE]); `None` selects the per-user OS default. + pub store_path: Option, } /// A warning projected into `{code, message, severity}` for machine surfaces. @@ -216,6 +178,11 @@ impl StatusWarning { } /// The complete, serializable typeshed source status shared by CLI, LSP, and MCP. +/// +/// The active source is the whole trust story — custom = user-managed, bundled +/// = build-vetted, exact commit = attested at download and re-proven offline — +/// so there are no separate transport or provenance fields +/// ([STUBRES-TYPESHED-WARN]). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TypeshedStatus { /// Which source is serving step 3. @@ -226,25 +193,16 @@ pub struct TypeshedStatus { /// The verified root-tree SHA, when content was attested. #[serde(rename = "tree_identity")] pub tree: Option, - /// How the bytes were transported. - pub transport: Transport, /// License standing. pub license_status: LicenseStatus, /// Immutable license reference (a pinned URL), or `None` for `not supplied`. pub license_reference: Option, - /// Provenance strength and trust boundary. - pub provenance: Provenance, - /// Whether the activated source was authenticated as a signed release. - /// - /// Typeshed currently publishes commits and archives rather than signed - /// releases, so every built-in acquisition path reports `false`. - pub signed_release: bool, /// Ordered, composable status warnings. pub warnings: Vec, } impl TypeshedStatus { - /// Whether any warning is elevated (fallback, blocked, or unverified). + /// Whether any warning is elevated (e.g. blocked license drift). #[must_use] pub fn has_high_severity(&self) -> bool { self.warnings @@ -268,6 +226,7 @@ mod tests { fn source_kind_labels_are_stable() { assert_eq!(SourceKind::ExactCommit.as_str(), "exact-commit"); assert_eq!(SourceKind::Bundled.as_str(), "bundled"); + assert_eq!(SourceKind::Custom.as_str(), "custom"); } #[test] @@ -307,21 +266,18 @@ mod tests { #[test] fn status_projects_and_orders_warnings() { let warnings = StatusWarning::list(&[ - TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled), - TypeshedWarning::Unverified, + TypeshedWarning::LicenseChanged, + TypeshedWarning::Unpinned(UnpinnedKind::BundledDefault), ]); - // Canonical spec-table order: UNPINNED precedes UNVERIFIED. + // Canonical spec-table order: UNPINNED precedes LICENSE CHANGED. let codes: Vec<&str> = warnings.iter().map(|w| w.code.as_str()).collect(); - assert_eq!(codes, vec!["UNPINNED", "UNVERIFIED"]); + assert_eq!(codes, vec!["UNPINNED", "LICENSE CHANGED"]); let status = TypeshedStatus { - active_source: SourceKind::Latest, - commit: None, + active_source: SourceKind::Bundled, + commit: oid(), tree: None, - transport: Transport::Codeload, license_status: LicenseStatus::Approved, license_reference: None, - provenance: Provenance::Unverified, - signed_release: false, warnings, }; assert!(status.has_high_severity()); @@ -333,13 +289,10 @@ mod tests { active_source: SourceKind::ExactCommit, commit: oid(), tree: None, - transport: Transport::Codeload, license_status: LicenseStatus::Approved, license_reference: Some( "https://github.com/python/typeshed/blob/83c2518/LICENSE".to_owned(), ), - provenance: Provenance::GithubTlsAttested, - signed_release: false, warnings: vec![], }; let value = serde_json::to_value(&status); @@ -354,15 +307,11 @@ mod tests { json.get("commit_identity").and_then(|v| v.as_str()), Some(FULL_SHA) ); - assert_eq!( - json.get("provenance").and_then(|v| v.as_str()), - Some("github-tls-attested") - ); - assert_eq!( - json.get("signed_release") - .and_then(serde_json::Value::as_bool), - Some(false) - ); + // The active source IS the trust story: no transport/provenance/ + // signed-release fields exist to drift out of sync with it. + for retired in ["transport", "provenance", "signed_release"] { + assert!(json.get(retired).is_none(), "retired field: {retired}"); + } } } } diff --git a/crates/basilisk-stubs/src/typeshed/store.rs b/crates/basilisk-stubs/src/typeshed/store.rs new file mode 100644 index 00000000..a88c7845 --- /dev/null +++ b/crates/basilisk-stubs/src/typeshed/store.rs @@ -0,0 +1,600 @@ +//! Implements [STUBRES-TYPESHED-STORE] and [STUBRES-TYPESHED-PIN]. See +//! docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-STORE. +//! +//! The content-addressed typeshed store: one immutable directory per commit, +//! written only by the download component and read — never repaired, never +//! evicted, never expired — by the checker. +//! +//! ```text +//! /<40-hex commit sha>/ +//! commit-object # raw Git commit object; hashes to the directory name +//! manifest.json # the commit's full Git tree listing (path, blob SHA, mode) +//! stdlib/… LICENSE NOTICE… +//! ``` +//! +//! Reading IS the pin verification ([STUBRES-TYPESHED-PIN]), fully offline: +//! +//! 1. hash `commit-object` — it MUST equal the pinned SHA; +//! 2. read the root-tree SHA out of that verified commit object; +//! 3. hash every materialised file into Git blob IDs and re-hash the full +//! manifest listing into Git tree objects — the root MUST equal that SHA. +//! +//! The manifest lists the commit's **entire** repository tree while only the +//! `stdlib/` subtree and relevant legal files are materialised on disk. That +//! subset is still fully bound: a materialised file's blob ID is computed from +//! its actual bytes, every other ID comes from the manifest, and any lie in +//! either changes the reconstructed root away from the SHA the verified commit +//! object names. There is no waiver. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +use super::archive::{Archive, ArchiveEntry, ArchiveVfs}; +use super::bundle::approved_license_manifest; +use super::gate::manifest::is_legal_file; +use super::gate::{license_gate, safety_gate, shape_gate, SafetyLimits}; +use super::gittree::{ + commit_root_tree, git_blob_oid, git_commit_oid, reconstruct_root_tree_oid, FileMode, GitFile, + Oid, +}; +use super::snapshot::Snapshot; +use super::source::{LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus}; + +/// The raw commit object file inside a store entry. +pub const COMMIT_OBJECT_FILE: &str = "commit-object"; +/// The tree-listing manifest inside a store entry. +pub const MANIFEST_FILE: &str = "manifest.json"; + +static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// One leaf of the commit's full repository tree. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoreTreeFile { + /// Repository-relative path. + pub path: String, + /// Full Git blob object ID (hex). + pub oid: String, + /// Canonical Git leaf mode (`100644`, `100755`, `120000`, or `160000`). + pub mode: String, +} + +/// The store entry manifest: the commit's complete Git tree listing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoreManifest { + /// The commit SHA (hex) — must match the directory name. + pub commit: String, + /// The commit's root-tree SHA (hex) — must match the commit object. + pub tree: String, + /// Every leaf of the commit's repository tree. + pub tree_files: Vec, +} + +/// Why a store entry could not activate. Paths never appear here; the caller +/// knows the store root and the SHA it asked for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum StoreError { + /// No entry directory exists for the requested commit. + #[error("no store entry for this commit")] + Missing, + /// The entry exists but failed offline verification or is unreadable. + #[error("store entry failed offline verification")] + Corrupt, + /// The entry verified but its legal-file identity is not the approved one. + #[error("store entry license identity changed")] + LicenseChanged, +} + +/// The store entry directory for a commit. +#[must_use] +pub fn entry_dir(store_root: &Path, commit: Oid) -> PathBuf { + store_root.join(commit.to_hex()) +} + +/// Whether a store entry materialises this tree path on disk: the `stdlib/` +/// subtree plus the relevant legal files ([STUBRES-TYPESHED-STORE]). The rule +/// is fixed in code — never in the manifest — so a tampered manifest cannot +/// silently shrink what must exist on disk. +#[must_use] +pub fn is_materialized(path: &str) -> bool { + path.starts_with("stdlib/") || is_legal_file(path) +} + +/// Read and verify one store entry into an immutable snapshot +/// ([STUBRES-TYPESHED-PIN]). `explicit` records whether the pin is a user's +/// `typeshed-commit` (suppressing `UNPINNED`) or the bundled default. +/// +/// # Errors +/// +/// Returns [`StoreError::Missing`] when no entry exists, +/// [`StoreError::LicenseChanged`] on approved-identity drift, and +/// [`StoreError::Corrupt`] on any verification failure. +pub fn read_snapshot( + store_root: &Path, + commit: Oid, + explicit: bool, +) -> Result { + let dir = entry_dir(store_root, commit); + if !dir.is_dir() { + return Err(StoreError::Missing); + } + let tree = verified_commit_tree(&dir, commit)?; + let manifest = read_manifest(&dir, commit, tree)?; + let archive = verified_archive(&dir, &manifest)?; + gate(&archive)?; + build_snapshot(commit, tree, explicit, archive) +} + +/// Steps 1–2 of the chain: the stored commit object must hash to the pinned +/// SHA, and only then is its tree header trusted. +fn verified_commit_tree(dir: &Path, commit: Oid) -> Result { + let bytes = fs::read(dir.join(COMMIT_OBJECT_FILE)).map_err(|_error| StoreError::Corrupt)?; + if git_commit_oid(&bytes) != commit { + return Err(StoreError::Corrupt); + } + commit_root_tree(&bytes).map_err(|_error| StoreError::Corrupt) +} + +fn read_manifest(dir: &Path, commit: Oid, tree: Oid) -> Result { + let bytes = fs::read(dir.join(MANIFEST_FILE)).map_err(|_error| StoreError::Corrupt)?; + let manifest: StoreManifest = + serde_json::from_slice(&bytes).map_err(|_error| StoreError::Corrupt)?; + if manifest.commit != commit.to_hex() || manifest.tree != tree.to_hex() { + return Err(StoreError::Corrupt); + } + Ok(manifest) +} + +/// Step 3 of the chain: hash the materialised bytes, then re-hash the full +/// listing into Git tree objects and require the verified commit's root. +fn verified_archive(dir: &Path, manifest: &StoreManifest) -> Result { + let expected_root = Oid::from_hex(&manifest.tree).map_err(|_error| StoreError::Corrupt)?; + let mut git_files = Vec::with_capacity(manifest.tree_files.len()); + let mut entries = Vec::new(); + for file in &manifest.tree_files { + let mode = parse_mode(&file.mode)?; + let recorded = Oid::from_hex(&file.oid).map_err(|_error| StoreError::Corrupt)?; + let oid = if is_materialized(&file.path) { + // A materialised leaf's ID comes from its actual on-disk bytes, so + // tampering with either the file or its manifest row breaks the root. + if !matches!(mode, FileMode::Regular | FileMode::Executable) { + return Err(StoreError::Corrupt); + } + let data = read_materialized(dir, &file.path)?; + let actual = git_blob_oid(&data); + if actual != recorded { + return Err(StoreError::Corrupt); + } + entries.push(ArchiveEntry { + path: file.path.clone(), + mode, + data, + }); + actual + } else { + recorded + }; + git_files.push(GitFile { + path: file.path.clone(), + oid, + mode, + }); + } + let root = reconstruct_root_tree_oid(&git_files).map_err(|_error| StoreError::Corrupt)?; + if root != expected_root { + return Err(StoreError::Corrupt); + } + entries.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(Archive::new(entries)) +} + +/// Read one materialised file, refusing any path the safety rules would refuse +/// in an archive (the manifest is untrusted input until the root hash matches). +fn read_materialized(dir: &Path, path: &str) -> Result, StoreError> { + if path.split('/').any(|segment| { + segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\') + }) { + return Err(StoreError::Corrupt); + } + fs::read(dir.join(path)).map_err(|_error| StoreError::Corrupt) +} + +fn gate(archive: &Archive) -> Result<(), StoreError> { + safety_gate(archive, &SafetyLimits::default()).map_err(|_error| StoreError::Corrupt)?; + shape_gate(archive).map_err(|_error| StoreError::Corrupt)?; + let approved = approved_license_manifest().map_err(|_error| StoreError::Corrupt)?; + license_gate(archive, &approved).map_err(|_error| StoreError::LicenseChanged) +} + +fn build_snapshot( + commit: Oid, + tree: Oid, + explicit: bool, + archive: Archive, +) -> Result { + let identity = SourceIdentity::Commit { + commit, + pinned: explicit, + }; + let status = TypeshedStatus { + active_source: SourceKind::ExactCommit, + commit: Some(commit), + tree: Some(tree), + license_status: LicenseStatus::Approved, + license_reference: Some(format!( + "https://github.com/python/typeshed/blob/{commit}/LICENSE" + )), + warnings: Vec::new(), + }; + let uri_identity = identity.uri_component(); + Snapshot::build( + identity, + status, + ArchiveVfs::new(uri_identity, archive), + None, + ) + .map_err(|_error| StoreError::Corrupt) +} + +fn parse_mode(mode: &str) -> Result { + match mode { + "100644" => Ok(FileMode::Regular), + "100755" => Ok(FileMode::Executable), + "120000" => Ok(FileMode::Symlink), + "160000" => Ok(FileMode::Submodule), + _ => Err(StoreError::Corrupt), + } +} + +/// Everything the download component hands over for one accepted commit. +#[derive(Debug, Clone)] +pub struct StoreEntry { + /// The commit SHA the entry is addressed by. + pub commit: Oid, + /// The raw commit object whose hash IS `commit`. + pub commit_object: Vec, + /// The commit's full tree listing. + pub manifest: StoreManifest, + /// The materialised files (`stdlib/…` + legal files) with verified bytes. + pub files: Vec, +} + +/// Write one store entry atomically: everything lands in a staging directory +/// that is renamed into place, so an interrupted download leaves **nothing** +/// ([STUBRES-TYPESHED-DOWNLOAD]). An existing entry for the commit is kept — +/// entries are content-addressed and immutable, so there is nothing to update. +/// +/// # Errors +/// +/// Returns [`StoreError::Corrupt`] on any I/O or serialization failure. +pub fn write_entry(store_root: &Path, entry: &StoreEntry) -> Result<(), StoreError> { + let target = entry_dir(store_root, entry.commit); + if target.is_dir() { + return Ok(()); + } + fs::create_dir_all(store_root).map_err(|_error| StoreError::Corrupt)?; + let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let staging = store_root.join(format!(".stage-{}-{sequence}", std::process::id())); + let staged = stage_entry(&staging, entry); + match staged.and_then(|()| promote(&staging, &target)) { + Ok(()) => Ok(()), + Err(error) => { + let _ = fs::remove_dir_all(&staging); + Err(error) + } + } +} + +fn stage_entry(staging: &Path, entry: &StoreEntry) -> Result<(), StoreError> { + fs::create_dir(staging).map_err(|_error| StoreError::Corrupt)?; + fs::write(staging.join(COMMIT_OBJECT_FILE), &entry.commit_object) + .map_err(|_error| StoreError::Corrupt)?; + let manifest = + serde_json::to_vec_pretty(&entry.manifest).map_err(|_error| StoreError::Corrupt)?; + fs::write(staging.join(MANIFEST_FILE), manifest).map_err(|_error| StoreError::Corrupt)?; + for file in &entry.files { + let path = staging.join(&file.path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_error| StoreError::Corrupt)?; + } + fs::write(&path, &file.data).map_err(|_error| StoreError::Corrupt)?; + } + Ok(()) +} + +fn promote(staging: &Path, target: &Path) -> Result<(), StoreError> { + match fs::rename(staging, target) { + Ok(()) => Ok(()), + // A concurrent download of the same commit won the rename; the entry + // is content-addressed, so the winner's bytes are equally correct. + Err(_error) if target.is_dir() => { + let _ = fs::remove_dir_all(staging); + Ok(()) + } + Err(_error) => Err(StoreError::Corrupt), + } +} + +#[cfg(test)] +#[expect( + clippy::expect_used, + reason = "test-only store fixtures require infallible setup" +)] +mod tests { + use super::*; + + /// A minimal but complete repository tree: a materialised stdlib plus the + /// **real** bundle LICENSE (so the approved-identity gate passes), plus an + /// unmaterialised repo file bound only through the manifest. + fn fixture_entry() -> (StoreEntry, Oid) { + let bundle = super::super::bundle::bundled_snapshot().expect("bundled snapshot"); + let license_body = bundle.vfs.read("LICENSE").expect("bundle LICENSE").to_vec(); + let files = vec![ + ArchiveEntry { + path: "LICENSE".to_owned(), + mode: FileMode::Regular, + data: license_body, + }, + ArchiveEntry { + path: "stdlib/VERSIONS".to_owned(), + mode: FileMode::Regular, + data: b"os: 3.0-\n".to_vec(), + }, + ArchiveEntry { + path: "stdlib/os.pyi".to_owned(), + mode: FileMode::Regular, + data: b"def getcwd() -> str: ...\n".to_vec(), + }, + ]; + let unmaterialized = GitFile { + path: "README.md".to_owned(), + oid: git_blob_oid(b"readme body\n"), + mode: FileMode::Regular, + }; + let mut git_files: Vec = files + .iter() + .map(|entry| GitFile { + path: entry.path.clone(), + oid: git_blob_oid(&entry.data), + mode: entry.mode, + }) + .collect(); + git_files.push(unmaterialized.clone()); + let tree = reconstruct_root_tree_oid(&git_files).expect("fixture tree"); + let commit_object = + format!("tree {tree}\nauthor a 0 +0000\ncommitter a 0 +0000\n\nfixture\n") + .into_bytes(); + let commit = git_commit_oid(&commit_object); + let manifest = StoreManifest { + commit: commit.to_hex(), + tree: tree.to_hex(), + tree_files: git_files + .iter() + .map(|file| StoreTreeFile { + path: file.path.clone(), + oid: file.oid.to_hex(), + mode: file.mode.as_str().to_owned(), + }) + .collect(), + }; + ( + StoreEntry { + commit, + commit_object, + manifest, + files, + }, + commit, + ) + } + + #[test] + fn missing_entry_is_missing_never_created() { + let root = tempfile::tempdir().expect("tempdir"); + let (_, commit) = fixture_entry(); + assert_eq!( + read_snapshot(root.path(), commit, true).err(), + Some(StoreError::Missing) + ); + // The checker never creates, repairs, or evicts: reading leaves the + // store untouched ([STUBRES-TYPESHED-STORE] inert-store rule). + assert_eq!( + fs::read_dir(root.path()).expect("readdir").count(), + 0, + "a read must not write" + ); + } + + #[test] + fn write_then_read_verifies_the_full_offline_chain() { + let root = tempfile::tempdir().expect("tempdir"); + let (entry, commit) = fixture_entry(); + assert!(write_entry(root.path(), &entry).is_ok()); + let snapshot = read_snapshot(root.path(), commit, true).expect("verified entry"); + assert_eq!(snapshot.status.active_source, SourceKind::ExactCommit); + assert_eq!(snapshot.status.commit, Some(commit)); + assert!(snapshot.status.warnings.is_empty()); + assert_eq!( + snapshot.read_stub("os").map(|(_, body)| body), + Some("def getcwd() -> str: ...\n") + ); + // An unmaterialised repo file participates in the root hash but never + // reaches the VFS. + assert!(snapshot.vfs.read("README.md").is_none()); + // The default (bundled) pin keeps UNPINNED semantics via `pinned`. + let default_pin = read_snapshot(root.path(), commit, false).expect("default pin"); + assert!(!default_pin.identity.is_pinned()); + } + + #[test] + fn license_drift_blocks_a_chain_that_otherwise_verifies() { + let root = tempfile::tempdir().expect("tempdir"); + let (mut entry, _) = fixture_entry(); + // A consistent entry (commit object, manifest, and bytes all agree) + // whose LICENSE is not the build-approved identity: the chain passes, + // the License gate blocks ([STUBRES-TYPESHED-LICENSE]). + for file in &mut entry.files { + if file.path == "LICENSE" { + file.data = b"a different license\n".to_vec(); + } + } + let rebuilt = rebuild_identity(entry); + let commit = rebuilt.commit; + assert!(write_entry(root.path(), &rebuilt).is_ok()); + assert_eq!( + read_snapshot(root.path(), commit, true).err(), + Some(StoreError::LicenseChanged) + ); + } + + /// Recompute manifest, tree, and commit object after editing fixture bytes, + /// so the entry is self-consistent and only the approved identity differs. + fn rebuild_identity(mut entry: StoreEntry) -> StoreEntry { + let mut git_files: Vec = entry + .files + .iter() + .map(|file| GitFile { + path: file.path.clone(), + oid: git_blob_oid(&file.data), + mode: file.mode, + }) + .collect(); + git_files.push(GitFile { + path: "README.md".to_owned(), + oid: git_blob_oid(b"readme body\n"), + mode: FileMode::Regular, + }); + let tree = reconstruct_root_tree_oid(&git_files).expect("rebuilt tree"); + entry.commit_object = + format!("tree {tree}\nauthor a 0 +0000\ncommitter a 0 +0000\n\nfixture\n") + .into_bytes(); + entry.commit = git_commit_oid(&entry.commit_object); + entry.manifest = StoreManifest { + commit: entry.commit.to_hex(), + tree: tree.to_hex(), + tree_files: git_files + .iter() + .map(|file| StoreTreeFile { + path: file.path.clone(), + oid: file.oid.to_hex(), + mode: file.mode.as_str().to_owned(), + }) + .collect(), + }; + entry + } + + #[test] + fn any_mutated_byte_fails_the_pin() { + let root = tempfile::tempdir().expect("tempdir"); + let (entry, commit) = fixture_entry(); + assert!(write_entry(root.path(), &entry).is_ok()); + let stub = entry_dir(root.path(), commit).join("stdlib/os.pyi"); + fs::write(&stub, b"def getcwd() -> bytes: ...\n").expect("tamper"); + assert_eq!( + read_snapshot(root.path(), commit, true).err(), + Some(StoreError::Corrupt) + ); + } + + #[test] + fn a_tampered_commit_object_fails_before_its_tree_is_trusted() { + let root = tempfile::tempdir().expect("tempdir"); + let (entry, commit) = fixture_entry(); + assert!(write_entry(root.path(), &entry).is_ok()); + let commit_object = entry_dir(root.path(), commit).join(COMMIT_OBJECT_FILE); + // Rewrite the commit object to name a different tree; it no longer + // hashes to the directory's SHA, so nothing after it is consulted. + fs::write( + &commit_object, + b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\nforged\n", + ) + .expect("tamper"); + assert_eq!( + read_snapshot(root.path(), commit, true).err(), + Some(StoreError::Corrupt) + ); + } + + #[test] + fn a_manifest_lie_about_an_unmaterialized_file_breaks_the_root() { + let root = tempfile::tempdir().expect("tempdir"); + let (mut entry, commit) = fixture_entry(); + // Lie about the README blob: the root reconstruction then cannot reach + // the tree SHA the verified commit object names. + for file in &mut entry.manifest.tree_files { + if file.path == "README.md" { + file.oid = git_blob_oid(b"a different readme\n").to_hex(); + } + } + assert!(write_entry(root.path(), &entry).is_ok()); + assert_eq!( + read_snapshot(root.path(), commit, true).err(), + Some(StoreError::Corrupt) + ); + } + + #[test] + fn a_manifest_that_drops_a_materialized_file_breaks_the_root() { + let root = tempfile::tempdir().expect("tempdir"); + let (mut entry, commit) = fixture_entry(); + entry + .manifest + .tree_files + .retain(|file| file.path != "stdlib/os.pyi"); + assert!(write_entry(root.path(), &entry).is_ok()); + assert_eq!( + read_snapshot(root.path(), commit, true).err(), + Some(StoreError::Corrupt) + ); + } + + #[test] + fn traversal_paths_in_a_manifest_never_escape_the_entry() { + let root = tempfile::tempdir().expect("tempdir"); + let dir = root.path().join("0123456789012345678901234567890123456789"); + fs::create_dir_all(&dir).expect("entry dir"); + for path in ["stdlib/../../../etc/passwd", "stdlib//x.pyi", "stdlib/.\\x"] { + assert_eq!( + read_materialized(&dir, path).err(), + Some(StoreError::Corrupt), + "{path} must be refused" + ); + } + } + + #[test] + fn interrupted_staging_never_becomes_an_entry() { + let root = tempfile::tempdir().expect("tempdir"); + let (entry, commit) = fixture_entry(); + // Simulate an interrupt: a stale staging directory exists. + let staging = root.path().join(".stage-dead-0"); + fs::create_dir_all(staging.join("stdlib")).expect("staging"); + fs::write(staging.join(COMMIT_OBJECT_FILE), b"partial").expect("partial"); + assert_eq!( + read_snapshot(root.path(), commit, true).err(), + Some(StoreError::Missing), + "a staging directory is not an entry" + ); + assert!(write_entry(root.path(), &entry).is_ok()); + // The real entry activates regardless of the stale staging litter. + assert!(read_snapshot(root.path(), commit, true).is_ok()); + } + + #[test] + fn an_existing_entry_is_immutable_under_rewrites() { + let root = tempfile::tempdir().expect("tempdir"); + let (entry, commit) = fixture_entry(); + assert!(write_entry(root.path(), &entry).is_ok()); + let marker = entry_dir(root.path(), commit).join("stdlib/marker.pyi"); + fs::write(&marker, b"x: int\n").expect("marker"); + // A second write of the same commit is a no-op, not a replacement. + assert!(write_entry(root.path(), &entry).is_ok()); + assert!( + marker.exists(), + "content-addressed entries are never rewritten" + ); + } +} diff --git a/crates/basilisk-stubs/src/typeshed/transport.rs b/crates/basilisk-stubs/src/typeshed/transport.rs deleted file mode 100644 index 5cc0788e..00000000 --- a/crates/basilisk-stubs/src/typeshed/transport.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Implements [STUBRES-TYPESHED-ACQUIRE] transport. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE -//! -//! The injectable archive transport seam. -//! -//! Basilisk **never clones** ([STUBRES-TYPESHED-ACQUIRE]): a transport resolves -//! trusted commit→tree metadata and downloads the commit's archive over HTTPS. -//! This trait is the boundary between the acquisition backend and the network, -//! so the whole selection/gate pipeline is testable with a mock. The **trusted -//! recursive tree is the attestation authority** for per-blob object IDs and -//! modes — content is bound to it, not to the archive's self-reported metadata. - -mod http; - -use super::gittree::{FileMode, Oid}; -use super::source::Transport as SourceTransport; - -pub use http::HttpsTransport; - -/// Trusted commit→tree metadata, resolved from the GitHub API over TLS. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommitMetadata { - /// The full commit SHA. - pub commit: Oid, - /// The commit's root-tree SHA. - pub tree: Oid, -} - -/// One trusted recursive-tree entry: a repo-relative path, its blob object ID, -/// and its Git mode. These trusted modes and OIDs drive content attestation -/// because codeload archives do not preserve them. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TreeEntry { - /// Repo-relative path. - pub path: String, - /// The blob (or submodule commit) object ID. - pub oid: Oid, - /// The Git file mode. - pub mode: FileMode, -} - -/// A transport failure. Detailed URLs and credentials are redacted before this -/// crosses the acquisition boundary (the selector maps it to a redacted -/// `BackendError`; raw detail belongs only in redacted tracing). -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum TransportError { - /// Commit or `main` metadata could not be resolved. - #[error("metadata resolution failed")] - Metadata, - /// The archive could not be downloaded. - #[error("archive download failed")] - Download, - /// A configured mirror URL was not an authenticated HTTPS `{sha}` template. - #[error("invalid archive mirror configuration")] - InvalidMirror, -} - -/// An injectable archive transport. -/// -/// Implementations MUST use HTTPS and MUST NEVER invoke `git`, `git clone`, or -/// any subprocess. The production adapter always resolves commit/tree metadata -/// from the official GitHub API; a configured `{sha}` mirror replaces only the -/// archive-byte request, so Latest remains bound to current official metadata. -/// -/// [`fetch_archive`]: Transport::fetch_archive -/// [`resolve_latest`]: Transport::resolve_latest -pub trait Transport: Send + Sync + std::fmt::Debug { - /// Resolve `main` to its current commit and root-tree. - /// - /// # Errors - /// - /// Returns [`TransportError`] when official metadata cannot be resolved. - fn resolve_latest(&self) -> Result; - - /// Resolve one explicit commit to its trusted root tree. - /// - /// # Errors - /// - /// Returns [`TransportError`] when official metadata cannot bind that - /// exact commit to a root tree. - fn resolve_commit(&self, commit: Oid) -> Result; - - /// Fetch the trusted recursive tree for a commit (path → blob OID + mode). - /// - /// # Errors - /// - /// Returns [`TransportError`] when the tree metadata cannot be fetched. - fn fetch_tree(&self, root_tree: Oid) -> Result, TransportError>; - - /// Fetch a commit's archive (zipball) bytes. - /// - /// # Errors - /// - /// Returns [`TransportError`] when the archive cannot be downloaded. - fn fetch_archive(&self, commit: Oid) -> Result, TransportError>; - - /// Report the actual archive byte origin. Cache metadata persists this - /// value so a later configuration change cannot relabel cached bytes. - fn archive_transport(&self) -> SourceTransport; -} diff --git a/crates/basilisk-stubs/src/typeshed/warning.rs b/crates/basilisk-stubs/src/typeshed/warning.rs index a975cb38..5ce1a362 100644 --- a/crates/basilisk-stubs/src/typeshed/warning.rs +++ b/crates/basilisk-stubs/src/typeshed/warning.rs @@ -2,38 +2,40 @@ //! //! Composable typeshed source-status warnings. //! -//! The typing specification defines resolution order, not transport status, so +//! The typing specification defines resolution order, not source status, so //! these warnings are **status, never Python diagnostics** — CLI prints them on //! a stderr banner, the LSP surfaces them through `window/showMessage` plus //! Service Info (never `publishDiagnostics`), and MCP returns them as structured //! fields. They therefore cannot create conformance false positives //! ([STUBRES-TYPESHED-WARN]). //! -//! Warnings **compose**: a single source can be simultaneously `UNPINNED` and -//! `UNVERIFIED`, so the report carries an ordered list rather than one enum. +//! Warnings **compose**: a custom folder is simultaneously `UNPINNED` and +//! `USER-MANAGED SOURCE`, so the report carries an ordered list rather than one +//! enum. A missing or corrupt pinned source is NOT a warning — it is the +//! terminal `NO SOURCE` failure and analysis does not run +//! ([STUBRES-TYPESHED-OFFLINE]). use std::cmp::Ordering; /// Severity of a source-status warning. /// -/// Three orthogonal signals drive this: an unpinned/user-managed source is -/// advisory (it works, it is just not reproducible), whereas a bundled fallback, -/// a blocked license change, or disabled verification is high — analysis is -/// running against fallback or unattested content. +/// An unpinned or user-managed source is advisory (it works, it is just not +/// reproducible or not official); a blocked license change is high — analysis +/// is refusing content pending review. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum WarningSeverity { /// Informational: the source works but is not reproducible or not official. Advisory, - /// Elevated: analysis is on a fallback, blocked, or unverified source. + /// Elevated: activation is blocked or analysis needs attention. High, } /// Why a source is reported as unpinned ([STUBRES-TYPESHED-WARN]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum UnpinnedKind { - /// Latest `main` or the bundled snapshot — a build-time pin is **not** a user - /// pin, so even the shipped snapshot reports unpinned until the user pins. - LatestOrBundled, + /// No explicit `typeshed-commit`: the bundled snapshot serves by default, + /// and a build-time pin is **not** a user pin. + BundledDefault, /// A custom `typeshed-path` folder, whose contents can change on disk. CustomFolder, } @@ -47,18 +49,9 @@ pub enum UnpinnedKind { pub enum TypeshedWarning { /// The active source carries no explicit `typeshed-commit`. Unpinned(UnpinnedKind), - /// Latest could not resolve, download, or validate; the bundled snapshot is - /// in use and may be behind upstream. - DownloadFailed { - /// Full SHA of the bundled snapshot now serving step 3. - bundled_sha: String, - }, /// The build-approved LICENSE/NOTICE identity changed; activation was blocked /// pending human review ([STUBRES-TYPESHED-LICENSE]). LicenseChanged, - /// Content verification was disabled, so contents were not checked against - /// the selected tree. Never implies verified provenance. - Unverified, /// A custom, user-managed source supplies its own license and contents; it is /// never assigned typeshed's terms ([STUBRES-CUSTOM-TYPESHED]). UserManaged, @@ -67,24 +60,20 @@ pub enum TypeshedWarning { impl TypeshedWarning { /// Stable machine code, matching the spec's status table heading. #[must_use] - pub fn code(&self) -> &'static str { + pub const fn code(&self) -> &'static str { match self { Self::Unpinned(_) => "UNPINNED", - Self::DownloadFailed { .. } => "DOWNLOAD FAILED", Self::LicenseChanged => "LICENSE CHANGED", - Self::Unverified => "UNVERIFIED", Self::UserManaged => "USER-MANAGED SOURCE", } } /// Severity of this warning. #[must_use] - pub fn severity(&self) -> WarningSeverity { + pub const fn severity(&self) -> WarningSeverity { match self { Self::Unpinned(_) | Self::UserManaged => WarningSeverity::Advisory, - Self::DownloadFailed { .. } | Self::LicenseChanged | Self::Unverified => { - WarningSeverity::High - } + Self::LicenseChanged => WarningSeverity::High, } } @@ -92,20 +81,14 @@ impl TypeshedWarning { #[must_use] pub fn message(&self) -> String { match self { - Self::Unpinned(UnpinnedKind::LatestOrBundled) => { - "UNPINNED — choose the pinned-commit source to make this reproducible".to_owned() + Self::Unpinned(UnpinnedKind::BundledDefault) => { + "UNPINNED — pin a commit to make this reproducible".to_owned() } Self::Unpinned(UnpinnedKind::CustomFolder) => { "UNPINNED — folder contents can change; version or content-address the folder externally" .to_owned() } - Self::DownloadFailed { bundled_sha } => { - format!("DOWNLOAD FAILED — using bundled {bundled_sha}; may be behind upstream") - } Self::LicenseChanged => "LICENSE CHANGED — Basilisk update/review required".to_owned(), - Self::Unverified => { - "UNVERIFIED — contents were not checked against the selected tree".to_owned() - } Self::UserManaged => { "USER-MANAGED SOURCE — license and contents supplied by user".to_owned() } @@ -115,13 +98,11 @@ impl TypeshedWarning { /// Canonical display priority (lower shows first), matching the normative /// status-table order shared by CLI, LSP, and MCP. #[must_use] - fn priority(&self) -> u8 { + const fn priority(&self) -> u8 { match self { Self::Unpinned(_) => 0, - Self::DownloadFailed { .. } => 1, + Self::UserManaged => 1, Self::LicenseChanged => 2, - Self::Unverified => 3, - Self::UserManaged => 4, } } } @@ -148,77 +129,60 @@ mod tests { #[test] fn codes_match_spec_table() { assert_eq!( - TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled).code(), + TypeshedWarning::Unpinned(UnpinnedKind::BundledDefault).code(), "UNPINNED" ); - assert_eq!( - TypeshedWarning::DownloadFailed { - bundled_sha: "abc".to_owned() - } - .code(), - "DOWNLOAD FAILED" - ); assert_eq!(TypeshedWarning::LicenseChanged.code(), "LICENSE CHANGED"); - assert_eq!(TypeshedWarning::Unverified.code(), "UNVERIFIED"); assert_eq!(TypeshedWarning::UserManaged.code(), "USER-MANAGED SOURCE"); } #[test] - fn severities_are_three_orthogonal_signals() { + fn only_license_drift_is_high_severity() { assert_eq!( - TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled).severity(), + TypeshedWarning::Unpinned(UnpinnedKind::BundledDefault).severity(), WarningSeverity::Advisory ); assert_eq!( - TypeshedWarning::UserManaged.severity(), + TypeshedWarning::Unpinned(UnpinnedKind::CustomFolder).severity(), WarningSeverity::Advisory ); assert_eq!( - TypeshedWarning::DownloadFailed { - bundled_sha: "x".to_owned() - } - .severity(), - WarningSeverity::High + TypeshedWarning::UserManaged.severity(), + WarningSeverity::Advisory ); assert_eq!( TypeshedWarning::LicenseChanged.severity(), WarningSeverity::High ); - assert_eq!( - TypeshedWarning::Unverified.severity(), - WarningSeverity::High - ); } #[test] - fn messages_are_verbatim_and_carry_the_sha() { + fn messages_are_verbatim_from_the_spec_table() { // Exact strings — a mutant that edits a word is caught. assert_eq!( - TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled).message(), - "UNPINNED — choose the pinned-commit source to make this reproducible" + TypeshedWarning::Unpinned(UnpinnedKind::BundledDefault).message(), + "UNPINNED — pin a commit to make this reproducible" ); assert_eq!( TypeshedWarning::Unpinned(UnpinnedKind::CustomFolder).message(), "UNPINNED — folder contents can change; version or content-address the folder externally" ); - let msg = TypeshedWarning::DownloadFailed { - bundled_sha: "83c2518".to_owned(), - } - .message(); assert_eq!( - msg, - "DOWNLOAD FAILED — using bundled 83c2518; may be behind upstream" + TypeshedWarning::LicenseChanged.message(), + "LICENSE CHANGED — Basilisk update/review required" + ); + assert_eq!( + TypeshedWarning::UserManaged.message(), + "USER-MANAGED SOURCE — license and contents supplied by user" ); - assert!(msg.contains("83c2518")); } #[test] fn canonicalize_uses_normative_status_table_order_and_dedups() { let mut warnings = vec![ + TypeshedWarning::LicenseChanged, TypeshedWarning::UserManaged, TypeshedWarning::Unpinned(UnpinnedKind::CustomFolder), - TypeshedWarning::Unverified, - TypeshedWarning::LicenseChanged, TypeshedWarning::UserManaged, // duplicate ]; canonicalize(&mut warnings); @@ -226,34 +190,31 @@ mod tests { warnings, vec![ TypeshedWarning::Unpinned(UnpinnedKind::CustomFolder), - TypeshedWarning::LicenseChanged, - TypeshedWarning::Unverified, TypeshedWarning::UserManaged, + TypeshedWarning::LicenseChanged, ] ); } #[test] - fn unpinned_and_unverified_compose() { - // A single source can be both unpinned and unverified: distinct variants + fn custom_folder_warnings_compose() { + // A custom folder is both unpinned and user-managed: distinct variants // coexist in one list rather than collapsing to a single enum. let mut warnings = vec![ - TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled), - TypeshedWarning::Unverified, + TypeshedWarning::UserManaged, + TypeshedWarning::Unpinned(UnpinnedKind::CustomFolder), ]; canonicalize(&mut warnings); assert_eq!(warnings.len(), 2); assert_eq!( - warnings.first().map(TypeshedWarning::severity), - Some(WarningSeverity::Advisory) + warnings.first().map(TypeshedWarning::code), + Some("UNPINNED") ); } #[test] fn serde_round_trip() { - let warning = TypeshedWarning::DownloadFailed { - bundled_sha: "deadbeef".to_owned(), - }; + let warning = TypeshedWarning::Unpinned(UnpinnedKind::BundledDefault); let json = serde_json::to_string(&warning); assert!(json.is_ok(), "serialize"); if let Ok(text) = json { diff --git a/crates/basilisk-stubs/tests/support/typeshed_acquisition.rs b/crates/basilisk-stubs/tests/support/typeshed_acquisition.rs index 72414b1e..edc6aa2e 100644 --- a/crates/basilisk-stubs/tests/support/typeshed_acquisition.rs +++ b/crates/basilisk-stubs/tests/support/typeshed_acquisition.rs @@ -1,233 +1,93 @@ -use std::collections::HashMap; -use std::io::{Cursor, Write as _}; -use std::sync::{Arc, Mutex}; - +//! Shared store-entry fixtures for black-box Typeshed acceptance tests. +//! +//! Every fixture is honestly content-addressed: the commit SHA is the real +//! hash of a real commit object naming the real root tree of the files, so +//! the offline pin verification chain ([STUBRES-TYPESHED-PIN]) is exercised +//! for real — nothing is stubbed past the gates. +#![allow( + dead_code, + clippy::allow_attributes, + reason = "each acceptance test binary uses a subset of these shared fixtures" +)] + +use std::path::Path; + +use basilisk_stubs::typeshed::archive::ArchiveEntry; use basilisk_stubs::typeshed::bundle::bundled_snapshot; -use basilisk_stubs::typeshed::cache::DiskCache; use basilisk_stubs::typeshed::gittree::{ - git_blob_oid, reconstruct_root_tree_oid, FileMode, GitFile, Oid, + git_blob_oid, git_commit_oid, reconstruct_root_tree_oid, FileMode, GitFile, Oid, }; -use basilisk_stubs::typeshed::runtime::manager_for_request; -use basilisk_stubs::typeshed::source::{ - SourceSelection, Transport as SourceTransport, TypeshedRequest, +use basilisk_stubs::typeshed::source::{SourceSelection, TypeshedRequest}; +use basilisk_stubs::typeshed::store::{ + is_materialized, write_entry, StoreEntry, StoreManifest, StoreTreeFile, }; -use basilisk_stubs::typeshed::transport::{CommitMetadata, Transport, TransportError, TreeEntry}; -use zip::write::{SimpleFileOptions, ZipWriter}; -use zip::CompressionMethod; - -pub const A_SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -pub const B_SHA: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - -#[derive(Clone)] -pub struct Fixture { - pub metadata: CommitMetadata, - tree: Vec, - zip: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Operation { - ResolveLatest, - ResolveCommit(Oid), - FetchTree(Oid), - FetchArchive(Oid), -} - -#[derive(Debug)] -pub struct RecordingTransport { - latest: Option, - commits: HashMap, - trees: HashMap>, - archives: HashMap>, - origin: SourceTransport, - operations: Mutex>, -} - -impl RecordingTransport { - pub fn new(latest: Option, fixtures: &[Fixture], origin: SourceTransport) -> Self { - let latest = latest.and_then(|commit| { - fixtures - .iter() - .find(|fixture| fixture.metadata.commit == commit) - .map(|fixture| fixture.metadata.clone()) - }); - Self { - latest, - commits: fixtures - .iter() - .map(|fixture| (fixture.metadata.commit, fixture.metadata.clone())) - .collect(), - trees: fixtures - .iter() - .map(|fixture| (fixture.metadata.tree, fixture.tree.clone())) - .collect(), - archives: fixtures - .iter() - .map(|fixture| (fixture.metadata.commit, fixture.zip.clone())) - .collect(), - origin, - operations: Mutex::new(Vec::new()), - } - } - - pub fn operations(&self) -> Vec { - self.operations.lock().expect("operation lock").clone() - } - - fn record(&self, operation: Operation) { - self.operations - .lock() - .expect("operation lock") - .push(operation); - } -} - -impl Transport for RecordingTransport { - fn resolve_latest(&self) -> Result { - self.record(Operation::ResolveLatest); - self.latest.clone().ok_or(TransportError::Metadata) - } - - fn resolve_commit(&self, commit: Oid) -> Result { - self.record(Operation::ResolveCommit(commit)); - self.commits - .get(&commit) - .cloned() - .ok_or(TransportError::Metadata) - } - - fn fetch_tree(&self, root_tree: Oid) -> Result, TransportError> { - self.record(Operation::FetchTree(root_tree)); - self.trees - .get(&root_tree) - .cloned() - .ok_or(TransportError::Metadata) - } - - fn fetch_archive(&self, commit: Oid) -> Result, TransportError> { - self.record(Operation::FetchArchive(commit)); - self.archives - .get(&commit) - .cloned() - .ok_or(TransportError::Download) - } - - fn archive_transport(&self) -> SourceTransport { - self.origin - } -} - -pub fn oid(sha: &str) -> Oid { - Oid::from_hex(sha).expect("valid fixture SHA") -} -pub fn request( - selection: SourceSelection, - verify_content: bool, - use_cache: bool, -) -> TypeshedRequest { +/// A pin request against an explicit store root. +pub fn pinned_request(commit: Oid, store: &Path) -> TypeshedRequest { TypeshedRequest { - selection, - verify_content, - use_cache, - url_template: None, + selection: SourceSelection::Pinned { + commit, + explicit: true, + }, + store_path: Some(store.to_path_buf()), } } -pub fn fixture(commit: &str, marker: &str) -> Fixture { - let license = bundled_snapshot() +/// The build-approved LICENSE bytes, so the legal-identity gate passes. +pub fn approved_license() -> Vec { + bundled_snapshot() .expect("bundled snapshot") .vfs .read("LICENSE") .expect("approved license") - .to_vec(); - let lower = marker.to_ascii_lowercase(); - let files = vec![ - ("LICENSE".to_owned(), license), - ( - "stdlib/VERSIONS".to_owned(), - format!("os: 3.0-\n{lower}_only: 3.0-\n").into_bytes(), - ), - ( - "stdlib/os.pyi".to_owned(), - format!("GENERATION: str = \"{marker}\"\n").into_bytes(), - ), - ( - format!("stdlib/{lower}_only.pyi"), - format!("GENERATION: str = \"{marker}\"\n").into_bytes(), - ), - ( - format!("stubs/{lower}-demo/{lower}_demo.pyi"), - b"VALUE: int\n".to_vec(), - ), - ]; - fixture_from_files(commit, &files) + .to_vec() } -pub fn fixture_from_files(commit: &str, files: &[(String, Vec)]) -> Fixture { - let tree: Vec<_> = files +/// Build a complete, verifiable store entry from raw repository files. +pub fn entry_from_files(files: &[(String, Vec)]) -> StoreEntry { + let git_files: Vec = files .iter() - .map(|(path, data)| TreeEntry { + .map(|(path, data)| GitFile { path: path.clone(), oid: git_blob_oid(data), mode: FileMode::Regular, }) .collect(); - let git_files: Vec<_> = tree - .iter() - .map(|entry| GitFile { - path: entry.path.clone(), - oid: entry.oid, - mode: entry.mode, - }) - .collect(); - let root = reconstruct_root_tree_oid(&git_files).expect("fixture tree"); - Fixture { - metadata: CommitMetadata { - commit: oid(commit), - tree: root, - }, - tree, - zip: zip(commit, files), - } -} - -pub fn untrusted_fixture(commit: &str, files: &[(String, Vec)]) -> Fixture { - Fixture { - metadata: CommitMetadata { - commit: oid(commit), - tree: oid(B_SHA), - }, - tree: Vec::new(), - zip: zip(commit, files), - } -} - -fn zip(commit: &str, files: &[(String, Vec)]) -> Vec { - let mut bytes = Vec::new(); - { - let mut writer = ZipWriter::new(Cursor::new(&mut bytes)); - for (path, data) in files { - writer - .start_file( - format!("typeshed-{commit}/{path}"), - SimpleFileOptions::default() - .compression_method(CompressionMethod::Stored) - .unix_permissions(0o644), - ) - .expect("ZIP entry"); - writer.write_all(data).expect("ZIP body"); - } - let _ = writer.finish().expect("ZIP finish"); + let tree = reconstruct_root_tree_oid(&git_files).expect("fixture tree"); + let commit_object = + format!("tree {tree}\nauthor a 0 +0000\ncommitter a 0 +0000\n\nfixture\n") + .into_bytes(); + let commit = git_commit_oid(&commit_object); + let manifest = StoreManifest { + commit: commit.to_hex(), + tree: tree.to_hex(), + tree_files: git_files + .iter() + .map(|file| StoreTreeFile { + path: file.path.clone(), + oid: file.oid.to_hex(), + mode: file.mode.as_str().to_owned(), + }) + .collect(), + }; + StoreEntry { + commit, + commit_object, + manifest, + files: files + .iter() + .filter(|(path, _)| is_materialized(path)) + .map(|(path, data)| ArchiveEntry { + path: path.clone(), + mode: FileMode::Regular, + data: data.clone(), + }) + .collect(), } - bytes } -pub fn manager( - acquisition: TypeshedRequest, - transport: Arc, - cache: Option, -) -> basilisk_stubs::typeshed::manager::TypeshedManager { - let injected: Arc = transport; - manager_for_request(acquisition, injected, cache) +/// Write `entry` into `store` and return its pinned commit. +pub fn install(store: &Path, entry: &StoreEntry) -> Oid { + write_entry(store, entry).expect("store entry write"); + entry.commit } diff --git a/crates/basilisk-stubs/tests/typeshed_acquisition_acceptance_tests.rs b/crates/basilisk-stubs/tests/typeshed_acquisition_acceptance_tests.rs index 88489430..148b19fc 100644 --- a/crates/basilisk-stubs/tests/typeshed_acquisition_acceptance_tests.rs +++ b/crates/basilisk-stubs/tests/typeshed_acquisition_acceptance_tests.rs @@ -1,14 +1,15 @@ -//! Black-box acceptance coverage for runtime Typeshed source acquisition. +//! Black-box acceptance coverage for offline Typeshed source resolution. //! //! Implements the parent [TYPESHEDRT-MODEL], [TYPESHEDRT-WORK], and -//! [TYPESHEDRT-ACCEPTANCE] contracts through the following independent -//! [TYPESHEDRT-ACCEPTANCE-SOURCE], -//! [TYPESHEDRT-ACCEPTANCE-VERIFY], and -//! [TYPESHEDRT-ACCEPTANCE-OVERRIDES]. +//! [TYPESHEDRT-ACCEPTANCE] contracts through the production manager only: +//! resolution is a LOCAL read of the store, the bundle, or a custom folder +//! ([STUBRES-TYPESHED-OFFLINE]) — a missing source tanks hard with the +//! spec's `NO SOURCE` line, and nothing ever downloads +//! ([TYPESHEDRT-SEGREGATION]). #![expect( clippy::expect_used, - reason = "test-only archive and temporary-directory fixtures fail loudly" + reason = "test-only store and temporary-directory fixtures fail loudly" )] #[path = "support/typeshed_acquisition.rs"] @@ -16,75 +17,98 @@ mod support; use std::sync::Arc; -use basilisk_stubs::typeshed::bundle::{bundled_commit_sha, bundled_snapshot}; -use basilisk_stubs::typeshed::cache::DiskCache; +use basilisk_stubs::typeshed::runtime::production_manager; use basilisk_stubs::typeshed::selector::{BackendError, SelectionError}; use basilisk_stubs::typeshed::source::{ - Provenance, SourceIdentity, SourceKind, SourceSelection, Transport as SourceTransport, -}; -use basilisk_stubs::typeshed::transport::{HttpsTransport, TransportError}; -use support::{ - fixture, manager, oid, request, untrusted_fixture, Operation, RecordingTransport, A_SHA, B_SHA, + LicenseStatus, SourceIdentity, SourceKind, SourceSelection, TypeshedRequest, }; +use basilisk_stubs::typeshed::store::{entry_dir, StoreEntry}; +use support::{approved_license, entry_from_files, install, pinned_request}; -#[test] -fn production_acquisition_is_https_only_and_has_no_process_transport() { - assert!(HttpsTransport::new(Some("https://mirror.example/{sha}.zip".to_owned())).is_ok()); - assert_eq!( - HttpsTransport::new(Some("http://mirror.example/{sha}.zip".to_owned())).err(), - Some(TransportError::InvalidMirror) - ); - let runtime_source = include_str!("../src/typeshed/runtime.rs"); - let http_source = include_str!("../src/typeshed/transport/http.rs"); - for source in [runtime_source, http_source] { - assert!(!source.contains("std::process::Command")); - assert!(!source.contains("Command::new")); - assert!(!source.contains("git clone")); - assert!(!source.contains("git://")); +/// A custom-folder request; a custom source never touches the store. +fn custom_request(path: String) -> TypeshedRequest { + TypeshedRequest { + selection: SourceSelection::Custom { path }, + store_path: None, } +} - let a = fixture(A_SHA, "A"); - let transport = Arc::new(RecordingTransport::new( - Some(a.metadata.commit), - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let snapshot = manager( - request(SourceSelection::Latest, true, false), - Arc::clone(&transport), - None, - ) - .snapshot() - .expect("HTTPS-seam acquisition"); - assert_eq!(snapshot.status.transport, SourceTransport::Codeload); - assert_eq!( - transport.operations(), - vec![ - Operation::ResolveLatest, - Operation::FetchTree(a.metadata.tree), - Operation::FetchArchive(a.metadata.commit), - ] - ); +/// One complete generation whose stdlib carries `marker` so tests can prove +/// exactly which generation is being served. +fn generation(marker: &str) -> StoreEntry { + let lower = marker.to_ascii_lowercase(); + let files = vec![ + ("LICENSE".to_owned(), approved_license()), + ( + "stdlib/VERSIONS".to_owned(), + format!("os: 3.0-\n{lower}_only: 3.0-\n").into_bytes(), + ), + ( + "stdlib/os.pyi".to_owned(), + format!("GENERATION: str = \"{marker}\"\n").into_bytes(), + ), + ( + format!("stdlib/{lower}_only.pyi"), + format!("GENERATION: str = \"{marker}\"\n").into_bytes(), + ), + // Repository files outside stdlib/ are bound through the manifest but + // never materialised ([STUBRES-TYPESHED-STORE]). + ( + format!("stubs/{lower}-demo/{lower}_demo.pyi"), + b"VALUE: int\n".to_vec(), + ), + ]; + entry_from_files(&files) } +/// [TYPESHEDRT-SEGREGATION]: the analysis crates hold no network or process +/// seam — downloading is a different crate that nothing on this path can +/// reach even by mistake. #[test] -fn one_manager_exposes_only_the_selected_b_generation() { - let a = fixture(A_SHA, "A"); - let b = fixture(B_SHA, "B"); - let transport = Arc::new(RecordingTransport::new( - Some(b.metadata.commit), - &[a, b.clone()], - SourceTransport::Codeload, - )); - let acquisition = manager( - request(SourceSelection::Latest, true, false), - Arc::clone(&transport), - None, - ); - let first = acquisition.snapshot().expect("B snapshot"); - let second = acquisition.snapshot().expect("same B snapshot"); +fn resolution_sources_carry_no_network_or_process_seams() { + let sources = [ + ("runtime", include_str!("../src/typeshed/runtime.rs")), + ("selector", include_str!("../src/typeshed/selector.rs")), + ("store", include_str!("../src/typeshed/store.rs")), + ("manager", include_str!("../src/typeshed/manager.rs")), + ]; + let forbidden = [ + "ureq", + "TcpStream", + "std::process::Command", + "Command::new", + "git2::", + "gix::", + "http://", + "https://api.", + "codeload", + "fetch_archive", + "resolve_latest", + ]; + for (name, source) in sources { + for token in forbidden { + assert!( + !source.contains(token), + "forbidden network/process token `{token}` appeared in {name}" + ); + } + } +} + +/// [TYPESHEDRT-ACCEPTANCE-SOURCE]: one manager exposes exactly the pinned +/// generation — memoized, complete, and never mixed with a sibling entry. +#[test] +fn one_manager_exposes_only_the_pinned_b_generation() { + let store = tempfile::tempdir().expect("store root"); + let _a = install(store.path(), &generation("A")); + let b = install(store.path(), &generation("B")); + + let manager = production_manager(pinned_request(b, store.path())); + let first = manager.snapshot().expect("B snapshot"); + let second = manager.snapshot().expect("same B snapshot"); assert!(Arc::ptr_eq(&first, &second)); - assert_eq!(first.identity.commit(), Some(b.metadata.commit)); + assert_eq!(first.identity.commit(), Some(b)); + assert_eq!(first.status.active_source, SourceKind::ExactCommit); assert!(first .versions() .is_some_and(|versions| versions.contains("b_only"))); @@ -94,225 +118,78 @@ fn one_manager_exposes_only_the_selected_b_generation() { first.read_stub("os").map(|(_, body)| body), Some("GENERATION: str = \"B\"\n") ); - assert_eq!( - first.distribution_index.distribution("b_demo"), - Some("types-b-demo") + assert!( + first.status.warnings.is_empty(), + "an explicit verified pin carries no warnings: {:?}", + first.status.warnings ); - assert_eq!(transport.operations().len(), 3); } +/// [STUBRES-TYPESHED-PIN]: a pin that is not on this machine is terminal +/// `NO SOURCE` — the message names the recovery commands, no sibling entry or +/// bundle is substituted, and the store is left byte-for-byte untouched. #[test] -fn failed_latest_never_uses_cached_a_and_falls_back_to_bundle() { - let cache_dir = tempfile::tempdir().expect("cache directory"); - let cache = DiskCache::new(cache_dir.path()); - let a = fixture(A_SHA, "A"); - let seed = Arc::new(RecordingTransport::new( - Some(a.metadata.commit), - std::slice::from_ref(&a), - SourceTransport::Codeload, - )); - let _ = manager( - request(SourceSelection::Latest, true, true), - seed, - Some(cache.clone()), - ) - .snapshot() - .expect("seed A cache"); +fn a_missing_pin_is_terminal_no_source_and_never_downloads() { + let store = tempfile::tempdir().expect("store root"); + let _a = install(store.path(), &generation("A")); + let absent = generation("GHOST").commit; - let unavailable = Arc::new(RecordingTransport::new(None, &[], SourceTransport::Mirror)); - let fallback = manager( - request(SourceSelection::Latest, true, true), - Arc::clone(&unavailable), - Some(cache), - ) - .snapshot() - .expect("bundled fallback"); - assert_eq!(fallback.status.active_source, SourceKind::Bundled); - assert_eq!(fallback.status.commit, Some(oid(bundled_commit_sha()))); - assert_ne!(fallback.status.commit, Some(a.metadata.commit)); - assert!(fallback.module_index.path("a_only").is_none()); - assert_eq!(unavailable.operations(), vec![Operation::ResolveLatest]); + let listing_before = store_listing(store.path()); + let error = production_manager(pinned_request(absent, store.path())) + .snapshot() + .expect_err("an absent pin has no source"); assert_eq!( - fallback - .status - .warnings - .iter() - .map(|warning| warning.code.as_str()) - .collect::>(), - vec!["UNPINNED", "DOWNLOAD FAILED"] + error, + SelectionError::NoSource { + commit: absent, + reason: BackendError::Missing, + } + ); + let message = error.to_string(); + assert!(message.contains("NO SOURCE"), "loud status line: {message}"); + assert!( + message.contains(&absent.to_hex()), + "names the pin: {message}" + ); + assert!( + message.contains("basilisk typeshed download"), + "names the recovery command: {message}" ); -} - -#[test] -fn verification_off_waives_only_tree_binding() { - let approved_license = bundled_snapshot() - .expect("bundle") - .vfs - .read("LICENSE") - .expect("approved license") - .to_vec(); - let cases = [ - untrusted_fixture( - A_SHA, - &[ - ("../escape.pyi".to_owned(), b"VALUE: int\n".to_vec()), - ("LICENSE".to_owned(), approved_license.clone()), - ("stdlib/VERSIONS".to_owned(), b"os: 3.0-\n".to_vec()), - ("stdlib/os.pyi".to_owned(), b"VALUE: int\n".to_vec()), - ], - ), - untrusted_fixture( - A_SHA, - &[ - ("LICENSE".to_owned(), approved_license), - ("stdlib/os.pyi".to_owned(), b"VALUE: int\n".to_vec()), - ], - ), - untrusted_fixture( - A_SHA, - &[ - ("LICENSE".to_owned(), b"changed license\n".to_vec()), - ("stdlib/VERSIONS".to_owned(), b"os: 3.0-\n".to_vec()), - ("stdlib/os.pyi".to_owned(), b"VALUE: int\n".to_vec()), - ], - ), - ]; - for (index, fixture) in cases.into_iter().enumerate() { - let transport = Arc::new(RecordingTransport::new( - None, - std::slice::from_ref(&fixture), - SourceTransport::Codeload, - )); - let result = manager( - request( - SourceSelection::ExactCommit { - commit: fixture.metadata.commit, - }, - false, - false, - ), - Arc::clone(&transport), - None, - ) - .snapshot(); - let expected_reason = if index == 2 { - BackendError::LicenseChanged - } else { - BackendError::Validation - }; - assert!(matches!( - result, - Err(SelectionError::Exact { reason, .. }) if reason == expected_reason - )); - assert!(!transport - .operations() - .iter() - .any(|operation| matches!(operation, Operation::FetchTree(_)))); - } - - let valid = fixture(A_SHA, "A"); - let transport = Arc::new(RecordingTransport::new( - None, - std::slice::from_ref(&valid), - SourceTransport::Codeload, - )); - let snapshot = manager( - request( - SourceSelection::ExactCommit { - commit: valid.metadata.commit, - }, - false, - false, - ), - Arc::clone(&transport), - None, - ) - .snapshot() - .expect("unverified but otherwise gated snapshot"); - assert_eq!(snapshot.status.provenance, Provenance::Unverified); - assert!(snapshot.status.tree.is_none()); assert_eq!( - snapshot - .status - .warnings - .iter() - .map(|warning| warning.code.as_str()) - .collect::>(), - vec!["UNVERIFIED"] + store_listing(store.path()), + listing_before, + "resolution must never write or fetch" ); - assert!(!transport - .operations() - .iter() - .any(|operation| matches!(operation, Operation::FetchTree(_)))); } +/// [STUBRES-TYPESHED-PIN]: any mutated byte in a stored entry fails the +/// offline verification chain — the pin is corrupt, never silently served. #[test] -fn mirror_fetches_known_sha_and_exact_a_ignores_moved_main_b() { - let a = fixture(A_SHA, "A"); - let b = fixture(B_SHA, "B"); - let transport = Arc::new(RecordingTransport::new( - Some(b.metadata.commit), - &[a.clone(), b], - SourceTransport::Mirror, - )); - let snapshot = manager( - request( - SourceSelection::ExactCommit { - commit: a.metadata.commit, - }, - true, - false, - ), - Arc::clone(&transport), - None, - ) - .snapshot() - .expect("exact mirror snapshot"); - assert_eq!(snapshot.status.transport, SourceTransport::Mirror); - assert_eq!(snapshot.status.commit, Some(a.metadata.commit)); - assert_eq!( - snapshot.read_stub("os").map(|(_, body)| body), - Some("GENERATION: str = \"A\"\n") - ); - assert_eq!( - transport.operations(), - vec![ - Operation::ResolveCommit(a.metadata.commit), - Operation::FetchTree(a.metadata.tree), - Operation::FetchArchive(a.metadata.commit), - ] - ); +fn a_tampered_store_entry_is_terminal_no_source() { + let store = tempfile::tempdir().expect("store root"); + let entry = generation("A"); + let commit = install(store.path(), &entry); - let unavailable = Arc::new(RecordingTransport::new( - Some(oid(B_SHA)), - &[fixture(B_SHA, "B")], - SourceTransport::Mirror, - )); - let error = manager( - request( - SourceSelection::ExactCommit { - commit: a.metadata.commit, - }, - true, - false, - ), - Arc::clone(&unavailable), - None, - ) - .snapshot() - .expect_err("unavailable A cannot substitute B or a different bundle SHA"); - assert!(matches!( - error, - SelectionError::Exact { commit, .. } if commit == a.metadata.commit - )); + let stub = entry_dir(store.path(), commit).join("stdlib/os.pyi"); + std::fs::write(&stub, b"GENERATION: str = \"EVIL\"\n").expect("tamper fixture"); + + let error = production_manager(pinned_request(commit, store.path())) + .snapshot() + .expect_err("a tampered entry must not activate"); assert_eq!( - unavailable.operations(), - vec![Operation::ResolveCommit(a.metadata.commit)] + error, + SelectionError::NoSource { + commit, + reason: BackendError::Corrupt, + } ); } +/// [STUBRES-CUSTOM-TYPESHED]: a custom tree is canonical — never rescued by +/// the store or the bundle, never assigned typeshed's license, and loudly +/// user-managed. #[test] -fn custom_tree_is_canonical_and_never_rescued_by_download_or_bundle() { +fn custom_tree_is_canonical_and_never_rescued_by_store_or_bundle() { let custom = tempfile::tempdir().expect("custom root"); let stdlib = custom.path().join("stdlib"); std::fs::create_dir(&stdlib).expect("stdlib"); @@ -320,38 +197,23 @@ fn custom_tree_is_canonical_and_never_rescued_by_download_or_bundle() { std::fs::write(stdlib.join("os.pyi"), "GENERATION: str = \"CUSTOM\"\n").expect("custom os"); std::fs::write(stdlib.join("custom_only.pyi"), "VALUE: int\n").expect("custom module"); - let remote = fixture(A_SHA, "A"); - let transport = Arc::new(RecordingTransport::new( - Some(remote.metadata.commit), - &[remote], - SourceTransport::Codeload, - )); - let snapshot = manager( - request( - SourceSelection::Custom { - path: custom.path().to_string_lossy().into_owned(), - }, - true, - true, - ), - Arc::clone(&transport), - None, - ) - .snapshot() - .expect("custom snapshot"); + // A fully populated store must not bleed into the custom source. + let store = tempfile::tempdir().expect("store root"); + let _a = install(store.path(), &generation("A")); + + let snapshot = production_manager(custom_request(custom.path().to_string_lossy().into_owned())) + .snapshot() + .expect("custom snapshot"); assert!(matches!(snapshot.identity, SourceIdentity::Custom { .. })); - assert_eq!(snapshot.status.provenance, Provenance::UserManaged); - assert_eq!( - snapshot.status.license_status, - basilisk_stubs::typeshed::source::LicenseStatus::NotSupplied - ); + assert_eq!(snapshot.status.active_source, SourceKind::Custom); + assert_eq!(snapshot.status.commit, None); + assert_eq!(snapshot.status.license_status, LicenseStatus::NotSupplied); assert!(snapshot.status.license_reference.is_none()); assert_eq!( snapshot.read_stub("os").map(|(_, body)| body), Some("GENERATION: str = \"CUSTOM\"\n") ); assert!(snapshot.read_stub("a_only").is_none()); - assert!(transport.operations().is_empty()); assert_eq!( snapshot .status @@ -363,61 +225,39 @@ fn custom_tree_is_canonical_and_never_rescued_by_download_or_bundle() { ); } +/// [TYPESHEDRT-ACCEPTANCE-OVERRIDES]: custom-path failures are typed and +/// deterministic; there is no fallback and no second attempt with different +/// policy. #[test] fn custom_path_validation_is_typed_and_deterministic() { - let transport = Arc::new(RecordingTransport::new( - None, - &[], - SourceTransport::Codeload, - )); - let relative = manager( - request( - SourceSelection::Custom { - path: "workspace-relative-typeshed".to_owned(), - }, - true, - false, - ), - Arc::clone(&transport), - None, - ) - .snapshot() - .expect_err("runtime requires config-resolved absolute path"); + let relative = production_manager(custom_request("workspace-relative-typeshed".to_owned())) + .snapshot() + .expect_err("runtime requires config-resolved absolute path"); assert_eq!( relative, SelectionError::Custom(BackendError::InvalidConfiguration) ); - let absent = tempfile::tempdir().expect("parent").path().join("absent"); - let absolute_request = request( - SourceSelection::Custom { - path: absent.to_string_lossy().into_owned(), - }, - true, - false, - ); - let first = manager(absolute_request.clone(), Arc::clone(&transport), None) + let absent = tempfile::tempdir() + .expect("parent") + .path() + .join("absent") + .to_string_lossy() + .into_owned(); + let first = production_manager(custom_request(absent.clone())) .snapshot() .expect_err("nonexistent path"); - let second = manager(absolute_request, Arc::clone(&transport), None) + let second = production_manager(custom_request(absent)) .snapshot() .expect_err("same nonexistent path"); assert_eq!(first, SelectionError::Custom(BackendError::Custom)); assert_eq!(first, second); - let malformed = tempfile::tempdir().expect("malformed root"); - std::fs::write(malformed.path().join("os.pyi"), "VALUE: int\n").expect("misplaced stub"); - let error = manager( - request( - SourceSelection::Custom { - path: malformed.path().to_string_lossy().into_owned(), - }, - true, - false, - ), - transport, - None, - ) + let missing_stdlib = tempfile::tempdir().expect("malformed root"); + std::fs::write(missing_stdlib.path().join("os.pyi"), "VALUE: int\n").expect("misplaced stub"); + let error = production_manager(custom_request( + missing_stdlib.path().to_string_lossy().into_owned(), + )) .snapshot() .expect_err("required top-level stdlib directory"); assert_eq!(error, SelectionError::Custom(BackendError::Custom)); @@ -427,22 +267,33 @@ fn custom_path_validation_is_typed_and_deterministic() { std::fs::create_dir(&stdlib).expect("stdlib"); std::fs::write(stdlib.join("VERSIONS"), "this is not a version row\n").expect("VERSIONS"); std::fs::write(stdlib.join("os.pyi"), "VALUE: int\n").expect("stub"); - let error = manager( - request( - SourceSelection::Custom { - path: malformed.path().to_string_lossy().into_owned(), - }, - true, - false, - ), - Arc::new(RecordingTransport::new( - None, - &[], - SourceTransport::Codeload, - )), - None, - ) + let error = production_manager(custom_request( + malformed.path().to_string_lossy().into_owned(), + )) .snapshot() .expect_err("malformed custom index"); assert_eq!(error, SelectionError::Custom(BackendError::Custom)); } + +/// Every file under the store root, relative and sorted, with sizes — a +/// byte-level "nothing was written" witness. +fn store_listing(root: &std::path::Path) -> Vec<(String, u64)> { + fn walk(dir: &std::path::Path, root: &std::path::Path, into: &mut Vec<(String, u64)>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, root, into); + } else if let (Ok(relative), Ok(metadata)) = (path.strip_prefix(root), entry.metadata()) + { + into.push((relative.to_string_lossy().into_owned(), metadata.len())); + } + } + } + let mut listing = Vec::new(); + walk(root, root, &mut listing); + listing.sort(); + listing +} diff --git a/crates/basilisk-stubs/tests/typeshed_forbidden_policy_tests.rs b/crates/basilisk-stubs/tests/typeshed_forbidden_policy_tests.rs index ee4a2edd..f15da79f 100644 --- a/crates/basilisk-stubs/tests/typeshed_forbidden_policy_tests.rs +++ b/crates/basilisk-stubs/tests/typeshed_forbidden_policy_tests.rs @@ -1,22 +1,23 @@ //! Aggregate regression guard for [TYPESHEDRT-ACCEPTANCE-GATES] forbidden policy. #![allow(missing_docs)] -const CACHE: &str = include_str!("../src/typeshed/cache.rs"); const RUNTIME: &str = include_str!("../src/typeshed/runtime.rs"); const SOURCE: &str = include_str!("../src/typeshed/source.rs"); -const TRANSPORT: &str = include_str!("../src/typeshed/transport.rs"); +const STORE: &str = include_str!("../src/typeshed/store.rs"); +const SELECTOR: &str = include_str!("../src/typeshed/selector.rs"); const STUBS_LIB: &str = include_str!("../src/lib.rs"); +const STUBS_MANIFEST: &str = include_str!("../Cargo.toml"); const CHECK_CONTEXT: &str = include_str!("../../basilisk-checker/src/context.rs"); const CONSTRUCTOR_HELPERS: &str = include_str!("../../basilisk-checker/src/rules/constructors_call_init/helpers.rs"); #[test] fn production_sources_reject_forbidden_typeshed_policy() { - let acquisition_sources = [ - ("cache", CACHE), + let resolution_sources = [ ("runtime", RUNTIME), ("source", SOURCE), - ("transport", TRANSPORT), + ("store", STORE), + ("selector", SELECTOR), ]; let forbidden = [ "std::process::Command", @@ -28,7 +29,7 @@ fn production_sources_reject_forbidden_typeshed_policy() { "stale_checkout", ]; - for (source_name, source) in acquisition_sources { + for (source_name, source) in resolution_sources { for token in forbidden { assert!( !source.contains(token), @@ -36,6 +37,16 @@ fn production_sources_reject_forbidden_typeshed_policy() { ); } } + // The checker links this crate, and "the checker never downloads" is a + // property of the build ([TYPESHEDRT-SEGREGATION]): no HTTP client may + // ever appear in this crate's manifest — downloading lives in + // `basilisk-typeshed-fetch`. + for client in ["ureq", "reqwest", "hyper"] { + assert!( + !STUBS_MANIFEST.contains(client), + "HTTP client `{client}` must never appear in basilisk-stubs" + ); + } assert!(!std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("build.rs") .exists()); @@ -59,32 +70,31 @@ fn production_sources_reject_forbidden_typeshed_policy() { } #[test] -fn immutable_pins_cache_and_custom_paths_remain_supported() { - for required in ["ExactCommit", "CustomPath", "Bundled"] { +fn immutable_pins_store_and_custom_paths_remain_supported() { + for required in ["ExactCommit", "Custom", "Bundled"] { assert!( SOURCE.contains(required), - "required source selection `{required}` was removed" + "required source kind `{required}` was removed" ); } - // The reuse window is split by whether the selection can move. Both halves - // are asserted: dropping the freshness gate would let stale bytes stand in - // for `main`, and dropping the pinned path would put a needless network - // round-trip back in front of a commit that cannot change. - assert!( - RUNTIME.contains("cache.load_fresh(&key, unix_seconds_now())"), - "bytes standing in for the moving `main` reference must use the freshness gate" - ); - assert!( - RUNTIME.contains("cache.load_pinned(&key)"), - "an explicitly pinned commit must reuse cached bytes without a freshness gate" - ); + // Resolution is offline by construction ([STUBRES-TYPESHED-OFFLINE]): the + // moving-main freshness window died with in-checker downloads, so nothing + // in the resolution path may resurrect a TTL or wall-clock gate. + for (source_name, source) in [ + ("runtime", RUNTIME), + ("store", STORE), + ("selector", SELECTOR), + ] { + for token in ["CACHE_MAX_AGE", "load_fresh", "unix_seconds_now"] { + assert!( + !source.contains(token), + "TTL machinery `{token}` reappeared in {source_name}" + ); + } + } assert!( - CACHE.contains("CACHE_MAX_AGE_SECONDS") - && CACHE.contains("acquired_at_unix_seconds") - && CACHE.contains("sha256_hex(&zip)") - && CACHE.contains("CacheError::Mutation"), - "cached ZIP reuse must remain 24-hour limited when unpinned, and mutation \ - checked on every load regardless of pinning" + RUNTIME.contains("load_pinned"), + "an explicitly pinned commit must resolve from the local store" ); assert!( !CHECK_CONTEXT.contains("typeshed_path"), diff --git a/crates/basilisk-stubs/tests/typeshed_gittree_tests.rs b/crates/basilisk-stubs/tests/typeshed_gittree_tests.rs index 41eb9b79..c08f8227 100644 --- a/crates/basilisk-stubs/tests/typeshed_gittree_tests.rs +++ b/crates/basilisk-stubs/tests/typeshed_gittree_tests.rs @@ -1,4 +1,4 @@ -//! Acceptance tests for [STUBRES-TYPESHED-ACQUIRE] Git-tree binding. +//! Acceptance tests for [STUBRES-TYPESHED-PIN] Git-tree binding. #![expect(clippy::expect_used, reason = "acceptance test: expect is acceptable")] use basilisk_stubs::typeshed::gittree::{ diff --git a/crates/basilisk-stubs/tests/typeshed_status_tests.rs b/crates/basilisk-stubs/tests/typeshed_status_tests.rs index e237a346..fa1c1e53 100644 --- a/crates/basilisk-stubs/tests/typeshed_status_tests.rs +++ b/crates/basilisk-stubs/tests/typeshed_status_tests.rs @@ -10,13 +10,11 @@ use basilisk_stubs::typeshed::warning::{ }; #[test] -fn fallback_verification_and_unpinned_warnings_compose() { +fn unpinned_user_managed_and_license_warnings_compose() { let mut warnings = vec![ - TypeshedWarning::Unverified, - TypeshedWarning::DownloadFailed { - bundled_sha: "83c2518a9e6abbda0c44592c3483de459198f887".to_owned(), - }, - TypeshedWarning::Unpinned(UnpinnedKind::LatestOrBundled), + TypeshedWarning::LicenseChanged, + TypeshedWarning::UserManaged, + TypeshedWarning::Unpinned(UnpinnedKind::BundledDefault), ]; canonicalize(&mut warnings); @@ -25,7 +23,7 @@ fn fallback_verification_and_unpinned_warnings_compose() { .iter() .map(TypeshedWarning::code) .collect::>(), - vec!["UNPINNED", "DOWNLOAD FAILED", "UNVERIFIED"] + vec!["UNPINNED", "USER-MANAGED SOURCE", "LICENSE CHANGED"] ); assert_eq!(warnings[0].severity(), WarningSeverity::Advisory); assert_eq!(warnings[2].severity(), WarningSeverity::High); @@ -59,9 +57,10 @@ fn custom_source_is_unpinned_and_user_managed() { #[test] fn warnings_serialize_without_losing_their_variant() { - let warning = TypeshedWarning::DownloadFailed { - bundled_sha: "83c2518a9e6abbda0c44592c3483de459198f887".to_owned(), - }; + let warning = TypeshedWarning::Unpinned(UnpinnedKind::CustomFolder); let value = serde_json::to_value(&warning).expect("warning serializes"); - assert!(value.get("DownloadFailed").is_some()); + assert_eq!( + value.get("Unpinned").and_then(serde_json::Value::as_str), + Some("CustomFolder") + ); } diff --git a/crates/basilisk-stubs/tests/typeshed_target_acceptance_tests.rs b/crates/basilisk-stubs/tests/typeshed_target_acceptance_tests.rs index 8d6b9a0c..53f22849 100644 --- a/crates/basilisk-stubs/tests/typeshed_target_acceptance_tests.rs +++ b/crates/basilisk-stubs/tests/typeshed_target_acceptance_tests.rs @@ -18,24 +18,16 @@ use std::sync::Arc; use basilisk_stubs::pyi_parser::parse_pyi_source_for_target; use basilisk_stubs::types::{StubSource, StubTarget, StubTargetPlatform, StubTier}; use basilisk_stubs::typeshed::bundle::bundled_snapshot; -use basilisk_stubs::typeshed::source::{SourceSelection, Transport as SourceTransport}; +use basilisk_stubs::typeshed::runtime::production_manager; +use basilisk_stubs::typeshed::store::StoreEntry; use serde_json::Value; -use support::{ - fixture, fixture_from_files, manager, request, untrusted_fixture, Fixture, Operation, - RecordingTransport, A_SHA, -}; +use support::{approved_license, entry_from_files, install, pinned_request}; /// One immutable generation whose `VERSIONS` and guarded declarations differ -/// across explicit targets without changing the acquired commit. -fn target_fixture(commit: &str) -> Fixture { - let license = bundled_snapshot() - .expect("bundled snapshot") - .vfs - .read("LICENSE") - .expect("approved license") - .to_vec(); +/// across explicit targets without changing the pinned commit. +fn target_entry() -> StoreEntry { let files = vec![ - ("LICENSE".to_owned(), license), + ("LICENSE".to_owned(), approved_license()), ( "stdlib/VERSIONS".to_owned(), b"guarded: 3.8-\nlegacy_only: 3.8-3.10\nmodern_only: 3.11-\n".to_vec(), @@ -63,7 +55,7 @@ else: b"MODERN: int\n".to_vec(), ), ]; - fixture_from_files(commit, &files) + entry_from_files(&files) } fn targets() -> (StubTarget, StubTarget) { @@ -81,27 +73,19 @@ fn targets() -> (StubTarget, StubTarget) { fn active_target_fixture() -> ( Arc, - Arc, + tempfile::TempDir, ) { - let fixture = target_fixture(A_SHA); - let transport = Arc::new(RecordingTransport::new( - Some(fixture.metadata.commit), - std::slice::from_ref(&fixture), - SourceTransport::Codeload, - )); - let snapshot = manager( - request(SourceSelection::Latest, true, false), - Arc::clone(&transport), - None, - ) - .snapshot() - .expect("target fixture acquisition"); - (snapshot, transport) + let store = tempfile::tempdir().expect("store root"); + let commit = install(store.path(), &target_entry()); + let snapshot = production_manager(pinned_request(commit, store.path())) + .snapshot() + .expect("target fixture resolution"); + (snapshot, store) } #[test] fn same_sha_different_targets_use_the_active_versions_index() { - let (snapshot, _transport) = active_target_fixture(); + let (snapshot, _store) = active_target_fixture(); let identity = snapshot.identity.clone(); let (legacy, modern) = targets(); @@ -123,8 +107,8 @@ fn same_sha_different_targets_use_the_active_versions_index() { #[test] fn target_changes_never_infer_or_fetch_a_different_commit() { - let (snapshot, transport) = active_target_fixture(); - let operations_before_target_selection = transport.operations(); + let (snapshot, _store) = active_target_fixture(); + let pinned_identity = snapshot.identity.clone(); let (legacy, modern) = targets(); let (_, body) = snapshot.read_stub("guarded").expect("guarded body"); @@ -156,26 +140,15 @@ fn target_changes_never_infer_or_fetch_a_different_commit() { assert!(!modern_stub.variables.contains_key("legacy_name")); assert!(!modern_stub.variables.contains_key("posix_name")); - assert_eq!(transport.operations(), operations_before_target_selection); - assert!(matches!( - operations_before_target_selection.as_slice(), - [ - Operation::ResolveLatest, - Operation::FetchTree(_), - Operation::FetchArchive(commit) - ] if commit.to_hex() == A_SHA - )); + // The target selection changed NOTHING about the source: the identity is + // still the one pinned commit, and there is no seam through which a + // target could fetch or infer a different generation. + assert_eq!(snapshot.identity, pinned_identity); + assert_eq!(snapshot.status.commit, pinned_identity.commit()); } #[test] fn runtime_and_bundled_policy_manufactures_no_python_target() { - let ordinary_fixture = fixture(A_SHA, "NO_TARGET"); - let transport_fixture = untrusted_fixture(A_SHA, &[]); - assert_eq!( - ordinary_fixture.metadata.commit, - transport_fixture.metadata.commit - ); - let policy_sources = [ include_str!("../src/lib.rs"), include_str!("../src/typeshed/bundle.rs"), diff --git a/crates/basilisk-test-utils/src/cross_module.rs b/crates/basilisk-test-utils/src/cross_module.rs new file mode 100644 index 00000000..6f99f7bd --- /dev/null +++ b/crates/basilisk-test-utils/src/cross_module.rs @@ -0,0 +1,52 @@ +//! Implements [CHKARCH-TESTING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-TESTING +//! +//! Cross-module resolution fixtures for LSP feature tests. +//! +//! Feature tests that need `imported_symbols` populated from a real Typeshed +//! snapshot must enter through the salsa `cross_resolved_module` query — the +//! same query the LSP itself uses ([TYPESHEDRT-ACCEPTANCE-HOVER]). Building +//! that database is identical in every such test, so it lives here once. + +use std::path::PathBuf; +use std::sync::Arc; + +use basilisk_checker::imports::{ActiveTypeshed, ImportSearchPaths}; +use basilisk_checker::{ + cross_resolved_module, BasiliskDatabase, FileRegistry, ResolvedFile, SearchPathsInput, + SourceFile, WorkspaceFiles, +}; +use basilisk_resolver::ResolvedModule; + +/// Import search paths that resolve **only** through `typeshed`. +/// +/// `roots` are the workspace roots; pass an empty vector when the fixture has +/// no workspace files of its own. +#[must_use] +pub fn typeshed_search_paths(typeshed: ActiveTypeshed, roots: Vec) -> ImportSearchPaths { + ImportSearchPaths { + roots, + extra_paths: Vec::new(), + stub_paths: Vec::new(), + workspace_members: Vec::new(), + site_packages: None, + registry: None, + typeshed_snapshot: Some(typeshed), + } +} + +/// Resolve `source` as `main.py` through the salsa cross-module query, so its +/// `imported_symbols` carry everything the LSP would see at request time. +/// +/// Returns `None` when the source does not parse or resolve, leaving the +/// assertion (and its message) to the caller. +#[must_use] +pub fn cross_resolve(source: &str, search_paths: ImportSearchPaths) -> Option> { + let database = BasiliskDatabase::default(); + let search_input = SearchPathsInput::new(&database, search_paths); + let workspace = WorkspaceFiles::new(&database, FileRegistry::default()); + let file = SourceFile::new(&database, "main.py".to_owned(), source.to_owned()); + match cross_resolved_module(&database, file, search_input, workspace) { + ResolvedFile::Resolved(resolved) => Some(Arc::clone(resolved)), + ResolvedFile::ParseError(_) | ResolvedFile::ResolveError => None, + } +} diff --git a/crates/basilisk-test-utils/src/lib.rs b/crates/basilisk-test-utils/src/lib.rs index 40ffcd9b..c587d1fb 100644 --- a/crates/basilisk-test-utils/src/lib.rs +++ b/crates/basilisk-test-utils/src/lib.rs @@ -15,6 +15,9 @@ mod resolver_helpers; #[cfg(feature = "checker")] mod checker_helpers; +#[cfg(feature = "checker")] +mod cross_module; + #[cfg(feature = "salsa")] pub mod salsa_db; @@ -32,5 +35,8 @@ pub use resolver_helpers::resolve_src; #[cfg(feature = "checker")] pub use checker_helpers::{assert_diagnostics, Expected}; +#[cfg(feature = "checker")] +pub use cross_module::{cross_resolve, typeshed_search_paths}; + #[cfg(feature = "salsa")] pub use salsa_db::EventDb; diff --git a/crates/basilisk-typeshed-fetch/Cargo.toml b/crates/basilisk-typeshed-fetch/Cargo.toml new file mode 100644 index 00000000..d3709bbc --- /dev/null +++ b/crates/basilisk-typeshed-fetch/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "basilisk-typeshed-fetch" +description = "The ONLY typeshed network code: user-invoked download into the content-addressed store" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +# Implements [TYPESHEDRT-SEGREGATION]: this crate is the only place typeshed +# bytes may arrive from the network. `basilisk-stubs` and `basilisk-checker` +# must NEVER depend on it (scripts/check-dependency-shape.sh asserts that); +# only `basilisk-cli` and `basilisk-lsp` may, and only behind an explicit +# user action ([STUBRES-TYPESHED-DOWNLOAD]). + +[features] +# Exposes `basilisk_typeshed_fetch::testing` — the offline fake-GitHub fixture — +# so consumers (`basilisk-cli`) can test their download-invoking surfaces +# without a network or a duplicated fixture. +test-support = ["dep:zip"] + +[dependencies] +basilisk-stubs.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +# Authenticated-HTTPS adapter for GitHub metadata and codeload archives. +# `https_only` is also enforced by the adapter. +ureq = { version = "3.3.0", default-features = false, features = ["rustls"] } +# `test-support` only: encode synthetic codeload zipballs for the fixture. +zip = { version = "5.1.1", default-features = false, features = ["deflate"], optional = true } + +[dev-dependencies] +tempfile = "3" +# The crate's own tests always build the fixture module. +zip = { version = "5.1.1", default-features = false, features = ["deflate"] } + +[lints] +workspace = true diff --git a/crates/basilisk-typeshed-fetch/src/commit.rs b/crates/basilisk-typeshed-fetch/src/commit.rs new file mode 100644 index 00000000..7c1114de --- /dev/null +++ b/crates/basilisk-typeshed-fetch/src/commit.rs @@ -0,0 +1,169 @@ +//! Implements [STUBRES-TYPESHED-PIN] commit-object reconstruction. +//! +//! GitHub's commit API reports the attested content (`verification.payload`) +//! and any signature separately; Git stores the signature inside the commit +//! object as a `gpgsig` multi-line header. Reassembling them byte-exactly lets +//! the download prove — before anything is stored — that the object it will +//! save hashes to the requested SHA, and gives the checker the raw object it +//! later re-hashes offline. A reconstruction that does not hash to the +//! requested SHA fails the download; nothing is written. + +use basilisk_stubs::typeshed::gittree::{commit_root_tree, git_commit_oid, Oid}; + +/// A verified raw commit object and the identities it binds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitObject { + /// The raw object content (`git cat-file commit` bytes). + pub raw: Vec, + /// The commit SHA the raw content hashes to. + pub commit: Oid, + /// The root-tree SHA named by the verified content. + pub tree: Oid, +} + +/// Why reconstruction was rejected. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum CommitError { + /// The payload had no header/message boundary to place a signature at. + #[error("commit payload is malformed")] + MalformedPayload, + /// The reconstructed object does not hash to the SHA GitHub reported. + #[error("reconstructed commit object does not hash to the reported sha")] + HashMismatch, + /// The verified object's tree header is missing or malformed. + #[error("verified commit object has no readable tree header")] + MalformedTree, +} + +/// Reassemble and verify the raw commit object for `expected`. +/// +/// The signature (when present) is re-inserted as Git encodes it: a `gpgsig ` +/// header whose continuation lines — including the one produced by the +/// signature's own trailing newline — are prefixed with a single space. +/// +/// # Errors +/// +/// Returns [`CommitError`] when the payload is malformed, the reconstruction +/// does not hash to `expected`, or the verified object names no tree. +pub fn reconstruct( + payload: &str, + signature: Option<&str>, + expected: Oid, +) -> Result { + let raw = assemble(payload, signature)?; + let commit = git_commit_oid(&raw); + if commit != expected { + return Err(CommitError::HashMismatch); + } + let tree = commit_root_tree(&raw).map_err(|_error| CommitError::MalformedTree)?; + Ok(CommitObject { raw, commit, tree }) +} + +fn assemble(payload: &str, signature: Option<&str>) -> Result, CommitError> { + let Some(signature) = signature else { + return Ok(payload.as_bytes().to_vec()); + }; + // Headers end at the first blank line; the signature header goes last, + // directly before it, exactly where git puts it. + let boundary = payload.find("\n\n").ok_or(CommitError::MalformedPayload)?; + let mut raw = String::with_capacity(payload.len() + signature.len() + 16); + raw.push_str(&payload[..=boundary]); + raw.push_str("gpgsig "); + raw.push_str(&signature.replace('\n', "\n ")); + raw.push('\n'); + raw.push_str(&payload[boundary + 1..]); + Ok(raw.into_bytes()) +} + +#[cfg(test)] +#[expect( + clippy::expect_used, + reason = "test-only fixtures parse a vendored real API response" +)] +mod tests { + use serde::Deserialize; + + use super::*; + + /// A real `python/typeshed` API response (the bundled commit), captured + /// verbatim: GPG-signed by GitHub's web-flow key, so it exercises the + /// signature re-insertion path against ground truth. + const REAL_COMMIT_JSON: &str = include_str!("../testdata/commit_83c2518.json"); + + #[derive(Deserialize)] + struct Fixture { + sha: String, + tree: String, + payload: String, + signature: Option, + } + + fn fixture() -> Fixture { + serde_json::from_str(REAL_COMMIT_JSON).expect("valid fixture json") + } + + /// The load-bearing test: a real signed typeshed commit reassembles + /// byte-exactly and hashes to its published SHA. If GitHub's payload + /// encoding or our continuation encoding ever drifts, this fails — and so + /// would every real download, loudly, writing nothing. + #[test] + fn a_real_signed_typeshed_commit_reconstructs_to_its_published_sha() { + let fixture = fixture(); + let expected = Oid::from_hex(&fixture.sha).expect("fixture sha"); + let object = reconstruct(&fixture.payload, fixture.signature.as_deref(), expected) + .expect("real commit must reconstruct"); + assert_eq!(object.commit, expected); + assert_eq!(object.tree.to_hex(), fixture.tree); + // The raw object is exactly what `git cat-file commit` prints: headers, + // gpgsig continuation (including the trailing `\n ` line), blank, message. + let text = String::from_utf8(object.raw).expect("utf8 commit object"); + assert!(text.starts_with(&format!("tree {}\n", fixture.tree))); + assert!(text.contains("\ngpgsig -----BEGIN PGP SIGNATURE-----\n")); + assert!(text.contains("-----END PGP SIGNATURE-----\n \n\n")); + } + + #[test] + fn an_unsigned_commit_is_the_payload_verbatim() { + let payload = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor a 0 +0000\ncommitter a 0 +0000\n\nx\n"; + let expected = git_commit_oid(payload.as_bytes()); + let object = reconstruct(payload, None, expected).expect("unsigned commit"); + assert_eq!(object.raw, payload.as_bytes()); + assert_eq!( + object.tree.to_hex(), + "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + ); + } + + /// Any tampering — payload or signature — misses the SHA and is terminal. + #[test] + fn a_tampered_payload_or_signature_fails_the_hash() { + let fixture = fixture(); + let expected = Oid::from_hex(&fixture.sha).expect("fixture sha"); + let tampered_payload = fixture.payload.replace("Refactor", "Sabotage"); + assert_eq!( + reconstruct(&tampered_payload, fixture.signature.as_deref(), expected).err(), + Some(CommitError::HashMismatch) + ); + let tampered_signature = fixture + .signature + .as_deref() + .map(|signature| signature.replace("PGP", "GPG")); + assert_eq!( + reconstruct(&fixture.payload, tampered_signature.as_deref(), expected).err(), + Some(CommitError::HashMismatch) + ); + // Dropping the signature from a signed commit also misses the SHA. + assert_eq!( + reconstruct(&fixture.payload, None, expected).err(), + Some(CommitError::HashMismatch) + ); + } + + #[test] + fn a_signed_payload_without_a_header_boundary_is_malformed() { + assert_eq!( + reconstruct("tree only no blank line", Some("sig"), git_commit_oid(b"x")).err(), + Some(CommitError::MalformedPayload) + ); + } +} diff --git a/crates/basilisk-stubs/src/typeshed/transport/http.rs b/crates/basilisk-typeshed-fetch/src/github.rs similarity index 60% rename from crates/basilisk-stubs/src/typeshed/transport/http.rs rename to crates/basilisk-typeshed-fetch/src/github.rs index 07b38f07..c2ed5861 100644 --- a/crates/basilisk-stubs/src/typeshed/transport/http.rs +++ b/crates/basilisk-typeshed-fetch/src/github.rs @@ -1,27 +1,24 @@ -//! Implements [STUBRES-TYPESHED-ACQUIRE] transport. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE +//! Implements [STUBRES-TYPESHED-DOWNLOAD] transport. See +//! docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-DOWNLOAD. //! -//! Authenticated-HTTPS GitHub metadata and archive adapter. -//! -//! "Authenticated" is load-bearing: requests to official GitHub hosts carry a -//! `GITHUB_TOKEN`/`GH_TOKEN` bearer credential when the environment supplies -//! one. A user-configured mirror never does — see [`CREDENTIAL_HOSTS`]. +//! Authenticated-HTTPS GitHub metadata and archive adapter — the only typeshed +//! network code in the workspace ([TYPESHEDRT-SEGREGATION]). There is no +//! mirror setting: downloads come only from `api.github.com` and +//! `codeload.github.com`, and the credential goes nowhere else. use std::time::Duration; +use basilisk_stubs::typeshed::gittree::{FileMode, Oid}; use serde::de::DeserializeOwned; use serde::Deserialize; -use super::{CommitMetadata, Transport, TransportError, TreeEntry}; -use crate::typeshed::gittree::{FileMode, Oid}; -use crate::typeshed::source::Transport as SourceTransport; - const API_ROOT: &str = "https://api.github.com/repos/python/typeshed"; const CODELOAD_ROOT: &str = "https://codeload.github.com/python/typeshed/zip"; const METADATA_LIMIT: usize = 32 * 1024 * 1024; const ARCHIVE_LIMIT: usize = 128 * 1024 * 1024; -/// Hosts the GitHub credential may be presented to. Anything else — including -/// every user-configured mirror — is contacted anonymously. +/// Hosts the GitHub credential may be presented to — nothing else exists in +/// this crate to contact. const CREDENTIAL_HOSTS: [&str; 2] = ["api.github.com", "codeload.github.com"]; /// Environment variables carrying a GitHub token, in precedence order. These are @@ -29,48 +26,98 @@ const CREDENTIAL_HOSTS: [&str; 2] = ["api.github.com", "codeload.github.com"]; /// shells are authenticated without any Basilisk-specific setup. const TOKEN_VARIABLES: [&str; 2] = ["GITHUB_TOKEN", "GH_TOKEN"]; -/// Production transport for official GitHub metadata and either codeload or a -/// configured authenticated-HTTPS `{sha}` archive mirror. -pub struct HttpsTransport { +/// A transport failure. URLs and credentials are redacted before this crosses +/// the download boundary; raw detail belongs only in redacted tracing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum TransportError { + /// Commit or tree metadata could not be resolved. + #[error("metadata resolution failed")] + Metadata, + /// The archive could not be downloaded. + #[error("archive download failed")] + Download, +} + +/// Commit metadata with the raw material for offline re-verification: the +/// signed payload and signature reconstruct the raw commit object +/// ([STUBRES-TYPESHED-PIN]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitInfo { + /// The resolved full commit SHA. + pub commit: Oid, + /// The commit's root-tree SHA as reported by the API (cross-checked + /// against the reconstructed commit object). + pub tree: Oid, + /// The commit content GitHub attests (raw object minus any signature header). + pub payload: String, + /// The PGP/SSH signature, when the commit is signed. + pub signature: Option, +} + +/// One trusted recursive-tree entry: a repo-relative path, its blob object ID, +/// and its Git mode. These trusted modes and OIDs drive content attestation +/// because codeload archives do not preserve them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeEntry { + /// Repo-relative path. + pub path: String, + /// The blob (or submodule commit) object ID. + pub oid: Oid, + /// The Git file mode. + pub mode: FileMode, +} + +/// Production GitHub client. Injectable via [`GithubApi`] so the download +/// pipeline is testable offline. +pub struct GithubClient { agent: ureq::Agent, - mirror_template: Option, - /// A GitHub token, when one was present in the environment. Anonymous - /// requests share a 60/hour/IP budget that CI and busy networks exhaust, - /// which surfaces as an opaque metadata failure; a token raises that to - /// 5000/hour. Never logged, never rendered in `Debug`, and never sent - /// anywhere but [`CREDENTIAL_HOSTS`]. token: Option, } -impl std::fmt::Debug for HttpsTransport { +impl std::fmt::Debug for GithubClient { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter - .debug_struct("HttpsTransport") - .field("mirror_configured", &self.mirror_template.is_some()) + .debug_struct("GithubClient") .field("credential", &credential_state(self.token.as_deref())) .finish_non_exhaustive() } } -impl HttpsTransport { - /// Build the production HTTPS adapter. Mirror URLs and credentials are - /// retained privately and never included in public errors or debug output. +/// The GitHub surface the download pipeline consumes. +pub trait GithubApi: Send + Sync { + /// Resolve a reference (`main` or a full SHA) to commit metadata carrying + /// the raw commit-object material. + /// + /// # Errors + /// + /// Returns [`TransportError`] when official metadata cannot be resolved. + fn resolve(&self, reference: &str) -> Result; + + /// Fetch the trusted recursive tree for a commit (path → blob OID + mode). /// /// # Errors /// - /// Returns [`TransportError::InvalidMirror`] unless a mirror is an HTTPS - /// URL containing exactly one `{sha}` placeholder. - pub fn new(mirror_template: Option) -> Result { - Self::with_token(mirror_template, token_from_environment()) + /// Returns [`TransportError`] when the tree metadata cannot be fetched. + fn fetch_tree(&self, root_tree: Oid) -> Result, TransportError>; + + /// Fetch a commit's archive (zipball) bytes from codeload. + /// + /// # Errors + /// + /// Returns [`TransportError`] when the archive cannot be downloaded. + fn fetch_archive(&self, commit: Oid) -> Result, TransportError>; +} + +impl GithubClient { + /// Build the production HTTPS adapter, reading the credential from the + /// environment. The token is retained privately and never appears in + /// errors, logs, or `Debug` output. + #[must_use] + pub fn new() -> Self { + Self::with_token(token_from_environment()) } - fn with_token( - mirror_template: Option, - token: Option, - ) -> Result { - if let Some(template) = mirror_template.as_deref() { - validate_mirror(template)?; - } + fn with_token(token: Option) -> Self { // A blank credential is normalized away here rather than at the // environment boundary, so no future caller can reintroduce // `Authorization: Bearer ` — which 401s a request that would have @@ -80,24 +127,21 @@ impl HttpsTransport { .https_only(true) .max_redirects(5) .max_redirects_will_error(true) - .timeout_global(Some(Duration::from_mins(1))) - .user_agent("basilisk-typeshed-runtime") + .timeout_global(Some(Duration::from_mins(2))) + .user_agent("basilisk-typeshed-fetch") .build(); tracing::debug!( credential = credential_state(token.as_deref()), - mirror_configured = mirror_template.is_some(), - "typeshed https transport configured" + "typeshed download transport configured" ); - Ok(Self { + Self { agent: config.into(), - mirror_template, token, - }) + } } /// Attach the GitHub credential when — and only when — `url` addresses an - /// official GitHub host. A mirror is operated by a third party, so sending - /// the user's token there would disclose it outside its trust boundary. + /// official GitHub host. fn authorize( &self, request: ureq::RequestBuilder, @@ -113,15 +157,6 @@ impl HttpsTransport { } } - fn metadata(&self, reference: &str) -> Result { - let url = format!("{API_ROOT}/commits/{reference}"); - let response: CommitResponse = self.get_json(&url)?; - let commit = Oid::from_hex(&response.sha).map_err(|_error| TransportError::Metadata)?; - let tree = - Oid::from_hex(&response.commit.tree.sha).map_err(|_error| TransportError::Metadata)?; - Ok(CommitMetadata { commit, tree }) - } - fn get_json(&self, url: &str) -> Result { let request = self .agent @@ -150,24 +185,32 @@ impl HttpsTransport { } serde_json::from_slice(&bytes).map_err(|_error| TransportError::Metadata) } - - fn archive_url(&self, commit: Oid) -> String { - let sha = commit.to_hex(); - self.mirror_template.as_ref().map_or_else( - || format!("{CODELOAD_ROOT}/{sha}"), - |template| template.replace("{sha}", &sha), - ) - } } -impl Transport for HttpsTransport { - fn resolve_latest(&self) -> Result { - self.metadata("main") +impl Default for GithubClient { + fn default() -> Self { + Self::new() } +} - fn resolve_commit(&self, commit: Oid) -> Result { - let expected = commit.to_hex(); - confirm_requested_commit(self.metadata(&expected)?, commit) +impl GithubApi for GithubClient { + fn resolve(&self, reference: &str) -> Result { + let url = format!("{API_ROOT}/commits/{reference}"); + let response: CommitResponse = self.get_json(&url)?; + let commit = Oid::from_hex(&response.sha).map_err(|_error| TransportError::Metadata)?; + let tree = + Oid::from_hex(&response.commit.tree.sha).map_err(|_error| TransportError::Metadata)?; + let payload = response + .commit + .verification + .payload + .ok_or(TransportError::Metadata)?; + Ok(CommitInfo { + commit, + tree, + payload, + signature: response.commit.verification.signature, + }) } fn fetch_tree(&self, root_tree: Oid) -> Result, TransportError> { @@ -176,7 +219,7 @@ impl Transport for HttpsTransport { } fn fetch_archive(&self, commit: Oid) -> Result, TransportError> { - let url = self.archive_url(commit); + let url = format!("{CODELOAD_ROOT}/{}", commit.to_hex()); let request = self.agent.get(&url); let mut response = self.authorize(request, &url).call().map_err(|error| { tracing::warn!( @@ -197,14 +240,6 @@ impl Transport for HttpsTransport { } Ok(bytes) } - - fn archive_transport(&self) -> SourceTransport { - if self.mirror_template.is_some() { - SourceTransport::Mirror - } else { - SourceTransport::Codeload - } - } } #[derive(Deserialize)] @@ -216,6 +251,14 @@ struct CommitResponse { #[derive(Deserialize)] struct CommitDetails { tree: TreeIdentity, + #[serde(default)] + verification: Verification, +} + +#[derive(Deserialize, Default)] +struct Verification { + payload: Option, + signature: Option, } #[derive(Deserialize)] @@ -239,23 +282,6 @@ struct ApiTreeEntry { sha: String, } -/// Confirm the API returned the commit that was asked for. -/// -/// GitHub resolves a `commits/{ref}` request against branches and tags as well -/// as SHAs, so a pin must be re-checked against the response rather than -/// assumed. Substituting a different commit under a pin would silently break -/// the reproducibility the pin exists to provide. -fn confirm_requested_commit( - metadata: CommitMetadata, - requested: Oid, -) -> Result { - if metadata.commit == requested { - Ok(metadata) - } else { - Err(TransportError::Metadata) - } -} - /// Convert a recursive-tree response into trusted leaves. /// /// Fails closed on a truncated listing or a root that is not the one requested: @@ -309,9 +335,6 @@ fn convert_tree_entry(entry: ApiTreeEntry) -> Option Option { - // Selection is split from the environment read so precedence and the - // blank-skip rule are testable without mutating process-wide state, which - // would race every other test in the binary. first_non_blank(TOKEN_VARIABLES.iter().map(|name| std::env::var(name).ok())) } @@ -350,20 +373,6 @@ fn status_of(error: &ureq::Error) -> u16 { } } -fn validate_mirror(template: &str) -> Result<(), TransportError> { - if template.matches("{sha}").count() != 1 { - return Err(TransportError::InvalidMirror); - } - let candidate = template.replace("{sha}", "0000000000000000000000000000000000000000"); - let uri = candidate - .parse::() - .map_err(|_error| TransportError::InvalidMirror)?; - if uri.scheme_str() != Some("https") || uri.host().is_none() { - return Err(TransportError::InvalidMirror); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -381,61 +390,8 @@ mod tests { } } - #[test] - fn mirror_requires_one_https_sha_placeholder() { - assert!(HttpsTransport::new(Some("https://mirror.test/{sha}.zip".to_owned())).is_ok()); - assert_eq!( - HttpsTransport::new(Some("http://mirror.test/{sha}.zip".to_owned())).err(), - Some(TransportError::InvalidMirror) - ); - assert_eq!( - HttpsTransport::new(Some("https://mirror.test/archive.zip".to_owned())).err(), - Some(TransportError::InvalidMirror) - ); - assert_eq!( - HttpsTransport::new(Some( - "https://mirror.test/{sha}/duplicate-{sha}.zip".to_owned() - )) - .err(), - Some(TransportError::InvalidMirror) - ); - assert_eq!( - HttpsTransport::new(Some("https:///{sha}.zip".to_owned())).err(), - Some(TransportError::InvalidMirror) - ); - } - - #[test] - fn archive_identity_and_transport_follow_the_configured_source() -> Result<(), TransportError> { - let commit = Oid::from_hex(SHA).map_err(|_error| TransportError::Metadata)?; - let official = HttpsTransport::new(None)?; - assert_eq!( - official.archive_url(commit), - format!("{CODELOAD_ROOT}/{SHA}") - ); - assert_eq!(official.archive_transport(), SourceTransport::Codeload); - - let mirror_template = "https://mirror.test/private/{sha}.zip"; - let mirror = HttpsTransport::new(Some(mirror_template.to_owned()))?; - assert_eq!( - mirror.archive_url(commit), - "https://mirror.test/private/0123456789abcdef0123456789abcdef01234567.zip" - ); - assert_eq!(mirror.archive_transport(), SourceTransport::Mirror); - - let debug = format!("{mirror:?}"); - assert!(debug.contains("mirror_configured: true")); - assert!( - !debug.contains("private"), - "mirror URLs must remain redacted" - ); - Ok(()) - } - - /// The credential boundary. A mirror is third-party infrastructure, so a - /// token that leaked into a mirror request would be disclosed outside the - /// trust boundary it was issued for. Substring matching would be the easy - /// way to get this wrong, so lookalike authorities are asserted explicitly. + /// The credential boundary. Substring matching would be the easy way to + /// get this wrong, so lookalike authorities are asserted explicitly. #[test] fn the_github_credential_is_confined_to_official_github_hosts() { for official in [ @@ -450,7 +406,6 @@ mod tests { } for foreign in [ - "https://mirror.test/private/0123456789abcdef.zip", "https://api.github.com.evil.test/repos/python/typeshed", "https://evil.test/proxy?upstream=api.github.com", "https://notapi.github.com/repos/python/typeshed", @@ -467,8 +422,8 @@ mod tests { /// Read the `Authorization` header value that `authorize` actually put on /// a request for `url`, so the boundary is asserted on the real outgoing /// header rather than on the host predicate alone. - fn authorization_for(transport: &HttpsTransport, url: &str) -> Option { - let request = transport.authorize(transport.agent.get(url), url); + fn authorization_for(client: &GithubClient, url: &str) -> Option { + let request = client.authorize(client.agent.get(url), url); request .headers_ref() .and_then(|headers| headers.get("Authorization")) @@ -477,39 +432,33 @@ mod tests { } /// The security boundary, asserted end-to-end on the outgoing header. - /// A leaked token would be disclosed to whoever operates the mirror, so - /// "no Authorization header on a mirror request" is the property that - /// actually matters — not merely that a helper returns false. #[test] - fn the_bearer_header_reaches_github_and_never_a_mirror() -> Result<(), TransportError> { + fn the_bearer_header_reaches_github_and_nothing_else() { let secret = "ghp_boundary_probe"; - let mirror = "https://mirror.test/private/{sha}.zip"; - let transport = - HttpsTransport::with_token(Some(mirror.to_owned()), Some(secret.to_owned()))?; + let client = GithubClient::with_token(Some(secret.to_owned())); assert_eq!( - authorization_for(&transport, &format!("{API_ROOT}/commits/main")), + authorization_for(&client, &format!("{API_ROOT}/commits/main")), Some(format!("Bearer {secret}")), "official metadata requests must be authenticated" ); assert_eq!( - authorization_for(&transport, &format!("{CODELOAD_ROOT}/{SHA}")), + authorization_for(&client, &format!("{CODELOAD_ROOT}/{SHA}")), Some(format!("Bearer {secret}")), "official archive requests must be authenticated" ); assert_eq!( - authorization_for(&transport, &mirror.replace("{sha}", SHA)), + authorization_for(&client, "https://mirror.test/private/archive.zip"), None, - "a third-party mirror must never receive the GitHub credential" + "a third-party host must never receive the GitHub credential" ); - let anonymous = HttpsTransport::with_token(None, None)?; + let anonymous = GithubClient::with_token(None); assert_eq!( authorization_for(&anonymous, &format!("{API_ROOT}/commits/main")), None, "with no credential the request stays anonymous rather than sending an empty bearer" ); - Ok(()) } /// Precedence and the blank-skip rule, asserted without mutating the @@ -531,66 +480,27 @@ mod tests { /// A blank token is worse than none: `Authorization: Bearer ` is rejected /// with 401, turning a working anonymous request into a hard failure. - /// CI runners export `GITHUB_TOKEN=` for credential-less jobs, so this is - /// the common case, not an edge case. #[test] - fn a_blank_credential_is_treated_as_absent() -> Result<(), TransportError> { - let blank = HttpsTransport::with_token(None, Some(" ".to_owned()))?; + fn a_blank_credential_is_treated_as_absent() { + let blank = GithubClient::with_token(Some(" ".to_owned())); assert_eq!(credential_state(blank.token.as_deref()), "absent"); - let real = HttpsTransport::with_token(None, Some("ghp_example".to_owned()))?; + let real = GithubClient::with_token(Some("ghp_example".to_owned())); assert_eq!(credential_state(real.token.as_deref()), "present"); - Ok(()) } /// `Debug` is reachable from tracing and error reporting, so it must expose /// only whether a credential exists — never its value. #[test] - fn debug_output_reveals_credential_presence_but_never_its_value() -> Result<(), TransportError> - { + fn debug_output_reveals_credential_presence_but_never_its_value() { let secret = "ghp_thismustnotappearanywhere"; - let transport = HttpsTransport::with_token(None, Some(secret.to_owned()))?; - let debug = format!("{transport:?}"); + let client = GithubClient::with_token(Some(secret.to_owned())); + let debug = format!("{client:?}"); assert!(debug.contains("credential: \"present\""), "got: {debug}"); assert!(!debug.contains(secret), "the token must never be rendered"); - let anonymous = HttpsTransport::with_token(None, None)?; + let anonymous = GithubClient::with_token(None); assert!(format!("{anonymous:?}").contains("credential: \"absent\"")); - Ok(()) - } - - /// A pin must be re-checked against what the API actually returned: - /// `commits/{ref}` resolves branches and tags too, so accepting the - /// response unverified would let a pin silently drift to another commit. - #[test] - fn a_resolved_commit_must_be_the_one_that_was_requested() -> Result<(), TransportError> { - let requested = Oid::from_hex(SHA).map_err(|_error| TransportError::Metadata)?; - let other = Oid::from_hex(OTHER_SHA).map_err(|_error| TransportError::Metadata)?; - let tree = Oid::from_hex(TREE_SHA).map_err(|_error| TransportError::Metadata)?; - - let matching = CommitMetadata { - commit: requested, - tree, - }; - assert_eq!( - confirm_requested_commit(matching, requested), - Ok(CommitMetadata { - commit: requested, - tree - }) - ); - assert_eq!( - confirm_requested_commit( - CommitMetadata { - commit: other, - tree - }, - requested - ), - Err(TransportError::Metadata), - "a substituted commit must fail closed" - ); - Ok(()) } /// A truncated listing or a mismatched root would under-report files, and @@ -629,25 +539,11 @@ mod tests { Err(TransportError::Metadata), "a listing with no root SHA must fail closed" ); - assert_eq!( - tree_entries( - TreeResponse { - sha: Some(TREE_SHA.to_owned()), - truncated: false, - tree: vec![api_entry("blob", "160000", SHA)], - }, - root - ), - Err(TransportError::Metadata), - "one noncanonical leaf rejects the whole listing" - ); Ok(()) } /// Rate limiting surfaces as 403 and was previously indistinguishable from - /// a parse failure, which is exactly how it got misdiagnosed. The status - /// must be recoverable for the log line; transport-level failures with no - /// response report 0 rather than inventing one. + /// a parse failure, which is exactly how it got misdiagnosed. #[test] fn failed_requests_expose_a_status_for_diagnostics() { assert_eq!(status_of(&ureq::Error::StatusCode(403)), 403); diff --git a/crates/basilisk-typeshed-fetch/src/lib.rs b/crates/basilisk-typeshed-fetch/src/lib.rs new file mode 100644 index 00000000..52666313 --- /dev/null +++ b/crates/basilisk-typeshed-fetch/src/lib.rs @@ -0,0 +1,251 @@ +//! Implements [STUBRES-TYPESHED-DOWNLOAD] and [TYPESHEDRT-SEGREGATION]. See +//! docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-DOWNLOAD. +//! +//! The typeshed download component — the ONLY typeshed code in the workspace +//! that opens a network connection. It runs only when a person invokes it +//! (`basilisk typeshed download`, the editor's Download buttons); nothing on +//! the analysis path can call it, because the checker's crates do not depend +//! on this one (asserted by `scripts/check-dependency-shape.sh`). +//! +//! A download: resolve commit metadata over authenticated HTTPS, reconstruct +//! the raw commit object and require it to hash to the requested SHA, fetch +//! the trusted recursive tree, download the codeload archive, bind every byte +//! to that tree through the four activation gates, then dump the accepted +//! `stdlib/` subset + legal files, the commit object, and the full tree +//! manifest into the content-addressed store. **A failure at any step writes +//! nothing** — no partial entry, no unverified entry, no config change. + +mod commit; +mod github; +#[cfg(any(test, feature = "test-support"))] +pub mod testing; + +use std::path::PathBuf; + +use basilisk_stubs::typeshed::archive::{Archive, ArchiveEntry}; +use basilisk_stubs::typeshed::bundle::approved_license_manifest; +use basilisk_stubs::typeshed::codec::{decode_zip, DecodeLimits, ZipLayout}; +use basilisk_stubs::typeshed::gate::{run_activation, GateConfig, GateError, SafetyLimits}; +use basilisk_stubs::typeshed::gittree::{git_blob_oid, FileMode, Oid}; +use basilisk_stubs::typeshed::runtime::default_store_path; +use basilisk_stubs::typeshed::store::{ + self, is_materialized, StoreEntry, StoreManifest, StoreTreeFile, +}; + +pub use github::{CommitInfo, GithubApi, GithubClient, TransportError, TreeEntry}; + +/// Coarse download progress, for surfaces that render it on the invoking +/// control ([LSPCFGED-TYPESHED-DOWNLOAD]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DownloadPhase { + /// Resolving commit metadata. + Resolving, + /// Fetching the trusted recursive tree. + FetchingTree, + /// Downloading the archive bytes. + FetchingArchive, + /// Running the activation gates and commit-object verification. + Verifying, + /// Writing the store entry. + Writing, +} + +/// A terminal, redacted download failure. Nothing was written. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DownloadError { + /// Commit or tree metadata could not be resolved (offline, rate-limited, + /// or inconsistent with the requested SHA). + #[error("typeshed metadata resolution failed")] + Metadata, + /// The archive could not be downloaded. + #[error("typeshed archive download failed")] + Download, + /// The archive or commit object failed verification against the requested + /// identity, or a gate rejected it. + #[error("typeshed download failed verification")] + Validation, + /// The legal-file identity is not the build-approved one; activation is + /// blocked pending review ([STUBRES-TYPESHED-LICENSE]). + #[error("typeshed license identity changed")] + LicenseChanged, + /// The verified entry could not be written to the store. + #[error("typeshed store write failed")] + Store, +} + +/// What a completed download materialised. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DownloadOutcome { + /// The commit now present, verified, in the store. **Download latest** + /// callers write this as `typeshed-commit`; **Download pinned** callers + /// write nothing ([STUBRES-TYPESHED-DOWNLOAD]). + pub commit: Oid, + /// The commit's verified root tree. + pub tree: Oid, +} + +/// Download the current `python/typeshed@main` into the store. The caller is +/// responsible for writing the returned SHA as `typeshed-commit` — that +/// config write is the *action's* contract, not this component's. +/// +/// # Errors +/// +/// Returns a redacted [`DownloadError`]; on any error nothing was written. +pub fn download_latest( + store_path: Option, + api: &dyn GithubApi, + progress: &dyn Fn(DownloadPhase), +) -> Result { + download("main", None, store_path, api, progress) +} + +/// Download one exact commit into the store (materialising an existing pin on +/// a machine that never downloaded it). Writes no configuration. +/// +/// # Errors +/// +/// Returns a redacted [`DownloadError`]; on any error nothing was written. +pub fn download_commit( + commit: Oid, + store_path: Option, + api: &dyn GithubApi, + progress: &dyn Fn(DownloadPhase), +) -> Result { + download(&commit.to_hex(), Some(commit), store_path, api, progress) +} + +fn download( + reference: &str, + expected: Option, + store_path: Option, + api: &dyn GithubApi, + progress: &dyn Fn(DownloadPhase), +) -> Result { + let store_root = store_path + .or_else(default_store_path) + .ok_or(DownloadError::Store)?; + progress(DownloadPhase::Resolving); + let info = api + .resolve(reference) + .map_err(|_error| DownloadError::Metadata)?; + // `commits/{ref}` resolves branches and tags as well as SHAs, so a pin is + // re-checked against the response rather than assumed. + if expected.is_some_and(|requested| requested != info.commit) { + return Err(DownloadError::Metadata); + } + // Reconstruct the raw commit object and require it to hash to the SHA — + // the same object the checker will re-hash offline on every activation + // ([STUBRES-TYPESHED-PIN]). Its tree must agree with the API's. + let object = commit::reconstruct(&info.payload, info.signature.as_deref(), info.commit) + .map_err(|_error| DownloadError::Validation)?; + if object.tree != info.tree { + return Err(DownloadError::Validation); + } + progress(DownloadPhase::FetchingTree); + let entries = api + .fetch_tree(object.tree) + .map_err(|_error| DownloadError::Metadata)?; + progress(DownloadPhase::FetchingArchive); + let bytes = api + .fetch_archive(info.commit) + .map_err(|_error| DownloadError::Download)?; + progress(DownloadPhase::Verifying); + let archive = decode_zip( + &bytes, + ZipLayout::CodeloadPrefixed, + &DecodeLimits::default(), + ) + .map_err(|_error| DownloadError::Validation)?; + let bound = bind_to_tree(&archive, &entries)?; + let approved = approved_license_manifest().map_err(|_error| DownloadError::Validation)?; + let config = GateConfig { + limits: SafetyLimits::default(), + approved_license: approved, + expected_root_tree: object.tree, + }; + let activation = run_activation(bound, &config, info.commit.to_hex()).map_err(|error| { + if matches!(error, GateError::License(_)) { + DownloadError::LicenseChanged + } else { + DownloadError::Validation + } + })?; + progress(DownloadPhase::Writing); + let entry = StoreEntry { + commit: info.commit, + commit_object: object.raw, + manifest: manifest_from(&info, &entries), + files: materialized_subset(activation.vfs.archive()), + }; + store::write_entry(&store_root, &entry).map_err(|_error| DownloadError::Store)?; + tracing::info!( + commit = %info.commit, + tree = %object.tree, + "typeshed commit downloaded and stored" + ); + Ok(DownloadOutcome { + commit: info.commit, + tree: object.tree, + }) +} + +/// Bind the decoded archive to the trusted tree: exact path set, trusted +/// modes, and per-blob object IDs. Codeload archives do not preserve Git +/// modes, so the tree's modes are authoritative +/// ([STUBRES-TYPESHED-DOWNLOAD] Content gate input). +fn bind_to_tree(archive: &Archive, trusted: &[TreeEntry]) -> Result { + if archive.len() != trusted.len() { + return Err(DownloadError::Validation); + } + let by_path: std::collections::BTreeMap<&str, &TreeEntry> = trusted + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect(); + if by_path.len() != trusted.len() { + return Err(DownloadError::Validation); + } + let mut entries = Vec::with_capacity(archive.len()); + for entry in archive.entries() { + let metadata = by_path + .get(entry.path.as_str()) + .ok_or(DownloadError::Validation)?; + if !matches!(metadata.mode, FileMode::Regular | FileMode::Executable) + || git_blob_oid(&entry.data) != metadata.oid + { + return Err(DownloadError::Validation); + } + entries.push(ArchiveEntry { + path: entry.path.clone(), + mode: metadata.mode, + data: entry.data.clone(), + }); + } + Ok(Archive::new(entries)) +} + +fn manifest_from(info: &github::CommitInfo, entries: &[TreeEntry]) -> StoreManifest { + StoreManifest { + commit: info.commit.to_hex(), + tree: info.tree.to_hex(), + tree_files: entries + .iter() + .map(|entry| StoreTreeFile { + path: entry.path.clone(), + oid: entry.oid.to_hex(), + mode: entry.mode.as_str().to_owned(), + }) + .collect(), + } +} + +fn materialized_subset(archive: &Archive) -> Vec { + archive + .entries() + .iter() + .filter(|entry| is_materialized(&entry.path)) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/basilisk-typeshed-fetch/src/testing.rs b/crates/basilisk-typeshed-fetch/src/testing.rs new file mode 100644 index 00000000..23654bcd --- /dev/null +++ b/crates/basilisk-typeshed-fetch/src/testing.rs @@ -0,0 +1,171 @@ +//! Offline fake-GitHub fixture for [STUBRES-TYPESHED-DOWNLOAD] tests: a +//! synthetic-but-honest repository at one commit (real Git hashing, real zip +//! encoding, the real approved LICENSE bytes). Shared by this crate's own +//! pipeline tests and — behind the `test-support` feature — by consumers +//! (`basilisk-cli`) exercising their download-invoking surfaces without a +//! network. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-DOWNLOAD. + +use std::io::Write as _; + +use basilisk_stubs::typeshed::bundle::bundled_snapshot; +use basilisk_stubs::typeshed::gittree::{ + git_blob_oid, git_commit_oid, reconstruct_root_tree_oid, FileMode, GitFile, Oid, +}; +use zip::write::{SimpleFileOptions, ZipWriter}; +use zip::CompressionMethod; + +use crate::github::CommitInfo; +use crate::{GithubApi, TransportError, TreeEntry}; + +/// A complete fake repository at one commit: the trusted tree, the raw commit +/// object (unsigned, so payload == raw), and the file bytes. Fields are public +/// so tests can tamper with individual pieces and assert the pipeline rejects +/// the inconsistency. +#[derive(Debug)] +pub struct FakeRepo { + /// The commit OID the payload hashes to. + pub commit: Oid, + /// The root tree OID named inside the payload. + pub tree: Oid, + /// The raw (unsigned) commit-object payload. + pub payload: String, + /// The trusted recursive tree listing. + pub tree_entries: Vec, + /// Repo-relative path → file bytes. + pub files: Vec<(String, Vec)>, +} + +/// Build an internally consistent [`FakeRepo`] carrying the build-approved +/// LICENSE bytes, a README, and a tiny `stdlib/` stub set. +#[must_use] +pub fn fake_repo() -> FakeRepo { + let license = bundled_snapshot() + .ok() + .and_then(|bundle| bundle.vfs.read("LICENSE").map(<[u8]>::to_vec)) + .unwrap_or_default(); + let files: Vec<(String, Vec)> = vec![ + ("LICENSE".to_owned(), license), + ("README.md".to_owned(), b"readme body\n".to_vec()), + ("stdlib/VERSIONS".to_owned(), b"os: 3.0-\n".to_vec()), + ( + "stdlib/os.pyi".to_owned(), + b"def getcwd() -> str: ...\n".to_vec(), + ), + ]; + let tree_entries: Vec = files + .iter() + .map(|(path, data)| TreeEntry { + path: path.clone(), + oid: git_blob_oid(data), + mode: FileMode::Regular, + }) + .collect(); + let git_files: Vec = tree_entries + .iter() + .map(|entry| GitFile { + path: entry.path.clone(), + oid: entry.oid, + mode: entry.mode, + }) + .collect(); + let tree = reconstruct_root_tree_oid(&git_files).unwrap_or_else(|_error| git_blob_oid(b"")); + let payload = + format!("tree {tree}\nauthor a 0 +0000\ncommitter a 0 +0000\n\nfixture\n"); + let commit = git_commit_oid(payload.as_bytes()); + FakeRepo { + commit, + tree, + payload, + tree_entries, + files, + } +} + +/// Encode the repo files as a codeload-style zipball (single root prefix). +fn zipball(repo: &FakeRepo) -> Vec { + let prefix = format!("python-typeshed-{}", repo.commit); + let mut buffer = Vec::new(); + { + let mut writer = ZipWriter::new(std::io::Cursor::new(&mut buffer)); + for (path, data) in &repo.files { + let options = SimpleFileOptions::default() + .compression_method(CompressionMethod::Stored) + .unix_permissions(0o644); + if writer + .start_file(format!("{prefix}/{path}"), options) + .is_err() + { + return Vec::new(); + } + if writer.write_all(data).is_err() { + return Vec::new(); + } + } + if writer.finish().is_err() { + return Vec::new(); + } + } + buffer +} + +/// One switchable failure per pipeline phase. +#[derive(Debug, Default, Clone, Copy)] +pub struct Faults { + /// Fail commit-metadata resolution. + pub resolve_fails: bool, + /// Fail the trusted-tree fetch. + pub tree_fails: bool, + /// Fail the archive download. + pub archive_fails: bool, +} + +/// A [`GithubApi`] serving one [`FakeRepo`], with switchable [`Faults`]. +#[derive(Debug)] +pub struct FakeApi { + /// The repository served; tests may tamper with it after construction. + pub repo: FakeRepo, + /// Which pipeline phases fail. + pub faults: Faults, + archive: Vec, +} + +impl FakeApi { + /// Serve `repo`, pre-encoding its zipball from the current file bytes. + #[must_use] + pub fn new(repo: FakeRepo) -> Self { + let archive = zipball(&repo); + Self { + repo, + faults: Faults::default(), + archive, + } + } +} + +impl GithubApi for FakeApi { + fn resolve(&self, _reference: &str) -> Result { + if self.faults.resolve_fails { + return Err(TransportError::Metadata); + } + Ok(CommitInfo { + commit: self.repo.commit, + tree: self.repo.tree, + payload: self.repo.payload.clone(), + signature: None, + }) + } + + fn fetch_tree(&self, _root_tree: Oid) -> Result, TransportError> { + if self.faults.tree_fails { + return Err(TransportError::Metadata); + } + Ok(self.repo.tree_entries.clone()) + } + + fn fetch_archive(&self, _commit: Oid) -> Result, TransportError> { + if self.faults.archive_fails { + return Err(TransportError::Download); + } + Ok(self.archive.clone()) + } +} diff --git a/crates/basilisk-typeshed-fetch/src/tests.rs b/crates/basilisk-typeshed-fetch/src/tests.rs new file mode 100644 index 00000000..d6ce8550 --- /dev/null +++ b/crates/basilisk-typeshed-fetch/src/tests.rs @@ -0,0 +1,252 @@ +//! Tests [STUBRES-TYPESHED-DOWNLOAD] end to end, offline: a fake GitHub API +//! serving a synthetic-but-honest repository (real Git hashing, real zip +//! encoding, the real approved LICENSE bytes) drives the full pipeline into a +//! temp store, and `basilisk_stubs::typeshed::store::read_snapshot` — the +//! checker's own offline reader — verifies what was written. Every failure +//! phase must leave the store byte-for-byte empty (atomic download). + +use std::cell::RefCell; + +use basilisk_stubs::typeshed::gittree::{git_commit_oid, reconstruct_root_tree_oid, GitFile}; +use basilisk_stubs::typeshed::store::read_snapshot; + +use super::testing::{fake_repo, FakeApi, Faults}; +use super::*; + +fn store_entry_count(root: &std::path::Path) -> usize { + std::fs::read_dir(root).map_or(0, Iterator::count) +} + +#[expect( + clippy::expect_used, + reason = "test-only fixtures require infallible setup" +)] +mod cases { + use super::*; + + /// The acceptance test: download latest, then read the entry back through + /// the checker's own offline verifier — the exact code path analysis uses. + #[test] + fn download_latest_round_trips_through_the_offline_store_reader() { + let root = tempfile::tempdir().expect("tempdir"); + let api = FakeApi::new(fake_repo()); + let phases = RefCell::new(Vec::new()); + let outcome = download_latest(Some(root.path().to_path_buf()), &api, &|phase| { + phases.borrow_mut().push(phase); + }) + .expect("download must succeed"); + assert_eq!(outcome.commit, api.repo.commit); + assert_eq!(outcome.tree, api.repo.tree); + assert_eq!( + phases.into_inner(), + vec![ + DownloadPhase::Resolving, + DownloadPhase::FetchingTree, + DownloadPhase::FetchingArchive, + DownloadPhase::Verifying, + DownloadPhase::Writing, + ] + ); + let snapshot = + read_snapshot(root.path(), outcome.commit, true).expect("offline verification"); + assert_eq!( + snapshot.read_stub("os").map(|(_, body)| body), + Some("def getcwd() -> str: ...\n") + ); + // Unmaterialised repo files participate in the pin, never in the VFS. + assert!(snapshot.vfs.read("README.md").is_none()); + } + + /// `download_commit` is for materialising an existing pin; the resolved + /// SHA must be the requested one, and any other answer is terminal. + #[test] + fn download_commit_verifies_the_resolved_sha_against_the_request() { + let root = tempfile::tempdir().expect("tempdir"); + let api = FakeApi::new(fake_repo()); + let requested = api.repo.commit; + assert!(download_commit( + requested, + Some(root.path().to_path_buf()), + &api, + &|_phase| {} + ) + .is_ok()); + + let other_root = tempfile::tempdir().expect("tempdir"); + let other = git_blob_oid(b"a different commit entirely"); + assert_eq!( + download_commit( + other, + Some(other_root.path().to_path_buf()), + &api, + &|_phase| {} + ), + Err(DownloadError::Metadata) + ); + assert_eq!(store_entry_count(other_root.path()), 0); + } + + /// Every failure phase leaves the store empty — the atomic-download + /// acceptance item ([STUBRES-TYPESHED-DOWNLOAD]). + #[test] + fn every_transport_failure_writes_nothing() { + let cases = [ + ( + Faults { + resolve_fails: true, + ..Faults::default() + }, + DownloadError::Metadata, + ), + ( + Faults { + tree_fails: true, + ..Faults::default() + }, + DownloadError::Metadata, + ), + ( + Faults { + archive_fails: true, + ..Faults::default() + }, + DownloadError::Download, + ), + ]; + for (faults, expected) in cases { + let root = tempfile::tempdir().expect("tempdir"); + let mut api = FakeApi::new(fake_repo()); + api.faults = faults; + assert_eq!( + download_latest(Some(root.path().to_path_buf()), &api, &|_phase| {}), + Err(expected) + ); + assert_eq!(store_entry_count(root.path()), 0, "store must stay empty"); + } + } + + /// A payload that does not hash to the reported SHA — a lying or tampered + /// metadata response — is rejected before anything is fetched or written. + #[test] + fn a_commit_payload_that_misses_its_sha_is_rejected() { + let root = tempfile::tempdir().expect("tempdir"); + let mut api = FakeApi::new(fake_repo()); + api.repo.payload = api.repo.payload.replace("fixture", "tampered"); + assert_eq!( + download_latest(Some(root.path().to_path_buf()), &api, &|_phase| {}), + Err(DownloadError::Validation) + ); + assert_eq!(store_entry_count(root.path()), 0); + } + + /// The API's tree SHA must agree with the tree named inside the verified + /// commit object; a divergence means the tree listing cannot be trusted. + #[test] + fn a_tree_sha_disagreeing_with_the_commit_object_is_rejected() { + let root = tempfile::tempdir().expect("tempdir"); + let mut api = FakeApi::new(fake_repo()); + api.repo.tree = git_blob_oid(b"some other tree"); + assert_eq!( + download_latest(Some(root.path().to_path_buf()), &api, &|_phase| {}), + Err(DownloadError::Validation) + ); + assert_eq!(store_entry_count(root.path()), 0); + } + + /// Archive bytes that differ from the trusted tree — one flipped stub — + /// fail content binding even though the zip itself is well-formed. + #[test] + fn tampered_archive_bytes_fail_content_binding_and_write_nothing() { + let root = tempfile::tempdir().expect("tempdir"); + let mut repo = fake_repo(); + for (path, data) in &mut repo.files { + if path == "stdlib/os.pyi" { + *data = b"def getcwd() -> bytes: ...\n".to_vec(); + } + } + // The tree entries still describe the ORIGINAL bytes. + let honest = fake_repo(); + repo.tree_entries = honest.tree_entries; + repo.tree = honest.tree; + repo.payload = honest.payload; + repo.commit = honest.commit; + let api = FakeApi::new(repo); + assert_eq!( + download_latest(Some(root.path().to_path_buf()), &api, &|_phase| {}), + Err(DownloadError::Validation) + ); + assert_eq!(store_entry_count(root.path()), 0); + } + + /// An archive with a file the tree does not list (or vice versa) fails the + /// exact path-set binding. + #[test] + fn an_archive_file_missing_from_the_tree_is_rejected() { + let root = tempfile::tempdir().expect("tempdir"); + let mut repo = fake_repo(); + repo.files.push(("smuggled.py".to_owned(), b"x\n".to_vec())); + let api = FakeApi::new(repo); + assert_eq!( + download_latest(Some(root.path().to_path_buf()), &api, &|_phase| {}), + Err(DownloadError::Validation) + ); + assert_eq!(store_entry_count(root.path()), 0); + } + + /// A commit whose legal identity drifted from the build-approved one is + /// blocked as `LicenseChanged` — distinct from generic validation so the + /// surface can say "pending legal review" ([STUBRES-TYPESHED-LICENSE]). + #[test] + fn license_drift_is_blocked_as_license_changed() { + let root = tempfile::tempdir().expect("tempdir"); + let mut repo = fake_repo(); + for (path, data) in &mut repo.files { + if path == "LICENSE" { + *data = b"a different license\n".to_vec(); + } + } + // Rebuild an internally consistent identity around the drifted bytes. + let tree_entries: Vec = repo + .files + .iter() + .map(|(path, data)| TreeEntry { + path: path.clone(), + oid: git_blob_oid(data), + mode: FileMode::Regular, + }) + .collect(); + let git_files: Vec = tree_entries + .iter() + .map(|entry| GitFile { + path: entry.path.clone(), + oid: entry.oid, + mode: entry.mode, + }) + .collect(); + let tree = reconstruct_root_tree_oid(&git_files).expect("rebuilt tree"); + repo.payload = + format!("tree {tree}\nauthor a 0 +0000\ncommitter a 0 +0000\n\nfixture\n"); + repo.commit = git_commit_oid(repo.payload.as_bytes()); + repo.tree = tree; + repo.tree_entries = tree_entries; + let api = FakeApi::new(repo); + assert_eq!( + download_latest(Some(root.path().to_path_buf()), &api, &|_phase| {}), + Err(DownloadError::LicenseChanged) + ); + assert_eq!(store_entry_count(root.path()), 0); + } + + /// Re-downloading an existing commit is a no-op success: the store is + /// content-addressed and immutable. + #[test] + fn redownloading_an_existing_commit_succeeds_without_rewriting() { + let root = tempfile::tempdir().expect("tempdir"); + let api = FakeApi::new(fake_repo()); + let store = Some(root.path().to_path_buf()); + let first = download_latest(store.clone(), &api, &|_phase| {}).expect("first download"); + let second = download_latest(store, &api, &|_phase| {}).expect("second download"); + assert_eq!(first, second); + assert_eq!(store_entry_count(root.path()), 1); + } +} diff --git a/crates/basilisk-typeshed-fetch/testdata/commit_83c2518.json b/crates/basilisk-typeshed-fetch/testdata/commit_83c2518.json new file mode 100644 index 00000000..3495a780 --- /dev/null +++ b/crates/basilisk-typeshed-fetch/testdata/commit_83c2518.json @@ -0,0 +1,6 @@ +{ + "sha": "83c2518a9e6abbda0c44592c3483de459198f887", + "tree": "66408ffce2750980efc6da09e8a6652733f852e4", + "payload": "tree 66408ffce2750980efc6da09e8a6652733f852e4\nparent d80e8b767311c5b81081a6fa2d0081c3582627cb\nauthor Sebastian Rittau 1784285952 +0200\ncommitter GitHub 1784285952 +0200\n\nRefactor `VERSIONS` handling (#16020)\n\n`parse_stdlib_versions_file` now returns a class with methods\n`supported_versions_for_module` and `is_supported`, obsoleting\nstand-alone function `supported_versions_for_module`.", + "signature": "-----BEGIN PGP SIGNATURE-----\n\nwsFcBAABCAAQBQJqWgsACRC1aQ7uu5UhlAAAepYQAG0WHy+zikx6p1K3k2n7qExn\nwzH+HbJisrknXppgDS0hQiEQ1NC4kKTY2wSnP9svwSwiu6Ouwh9JnzAdw677SR5e\nXImxjhLrzdALGmVtSvu11qN3cT57LXW2fmLBhYGfqqpsNsfzhxVPI+uVnceUoKCb\nT0K2MwrezGg6L5qxN1KE8CyzXqEkDJChtcCmstOhSmN1mtRXSWj3vFASm5KMgGI3\nJGf+lnCLx3O6d5mpMnCLO/nIUtKvYjTY/VBPXtRLuz0pdm5fZ0Xn9FzFIBgtVVH8\nkb3VSZHsOzS/h6HlF21ElsRa9onSIK3jYktyIt6k7H/7obM4sP1+9SHm8W1geYE6\nFHpJwEDMOA+cz+Xg+glsB3L2l5tcREOpMBaRA2QdW9LoSFEhZMmtCv2adfIjWT3d\nyqANLhbmp5rYcIds39wCtlJSbkGmR8SQVFkvF1ohoE8wVMT7XHtfJ7wMuBGvGTuq\n+DhAJiI9x/Jt9ekIEKWhlHEBGepWFVYvGm/rl6USciomVZq545w/TkuXmF9Rjwie\nrxjA8zbsDt0hu9jksZ6YcVmc23UJgNg7fdHLIS0kYZ4jhttZn+Ei89tQL++w0+mh\nOmYa7f/SVzB21nbYNbyi2fBySagBcKt/BwvFZymhBmgn0WDl36CSksfFrr6XEe9Z\n5jRKoFLINExTdrBzEJEi\n=9Z2A\n-----END PGP SIGNATURE-----\n" +} \ No newline at end of file diff --git a/docs/INDEX.md b/docs/INDEX.md index fcdce007..4aff1282 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -15,8 +15,8 @@ linked to an active plan. |---|---| | [Checker architecture](specs/CHECKER-ARCHITECTURE-SPEC.md) | Configuration, rules, diagnostics, analysis pipeline, CLI, and quality gates. | | [Type inference](specs/CHECKER-TYPE-INFERENCE-SPEC.md) | Expression/type inference and narrowing contracts, plus the target bidirectional/constraint architecture and its research grounding. | -| [Stub resolution](specs/CHECKER-STUB-RESOLUTION-SPEC.md) | Pinned typing-spec import order, custom typeshed, archive acquisition, bundled stdlib ZIP, provenance, and generation. | -| [Checker MCP service](specs/CHECKER-MCP-SPEC.md) | Packaged stdio lifecycle and the structured typeshed provenance/status tool. | +| [Stub resolution](specs/CHECKER-STUB-RESOLUTION-SPEC.md) | Pinned typing-spec import order, custom typeshed, offline pin verification against the store, the segregated download component, bundled stdlib ZIP, and generation. | +| [Checker MCP service](specs/CHECKER-MCP-SPEC.md) | Packaged stdio lifecycle and the structured typeshed source/status tool. | | [Checker cache](specs/CHECKER-CACHE-SPEC.md) | Opt-in content-addressed CLI result cache. | | [Rule tagging](specs/CHECKER-RULE-TAGGING-SPEC.md) | Rule provenance/category/free-form tags and conflict rules. | | [Compiler prototype](specs/COMPILER-ARCHITECTURE-SPEC.md) | Current checker-gated AST interpreter and fixture contract. | @@ -40,6 +40,8 @@ linked to an active plan. | [Website E2E](specs/WEBSITE-E2E-SPEC.md) | Navigation and responsive smoke tests. | | [Website screenshots](specs/WEBSITE-SCREENSHOTS-SPEC.md) | Verified CLI screenshot generation. | | [Website error pages](specs/WEBSITE-ERROR-PAGES-SPEC.md) | Generated per-diagnostic documentation. | +| [READMEs](specs/DOCS-README-SPEC.md) | One authored README per language, generated to GitHub, the VSIX (Marketplace + Open VSX), and PyPI. | +| [Repository standards](specs/REPO-STANDARDS-SPEC.md) | Root/`.github` gates: duplication budget, coverage thresholds, committed editor directories, Dependabot, CodeQL, and dependency review. | ## Active plans @@ -49,12 +51,12 @@ Plans contain only unfinished work. Delete a plan when its acceptance gate passe |---|---| | [Roadmap](plans/ROADMAP-NEXT-STEPS-PLAN.md) | Distribution follow-ups, scale, ecosystem, and links to focused plans. | | [Specification conformance audit](plans/SPEC-CONFORMANCE-AUDIT-PLAN.md) | Confirmed implementation/spec deviations. | -| [Configuration editor](plans/LSP-CONFIGURATION-EDITOR-PLAN.md) | Buffer safety, protocol E2E coverage, provenance/metadata, cross-editor clients, and release evidence. | +| [Configuration editor](plans/LSP-CONFIGURATION-EDITOR-PLAN.md) | Canonical fixability metadata, DTO drift test, config-projection consolidation, field provenance, protocol/adoption/suppression E2E coverage, accessibility verification, cross-editor clients, and release gates. | | [Formatting](plans/LSP-FORMATTING-PLAN.md) | VS Code default-formatter opt-in and published-artifact verification. | | [AI-assisted LSP](plans/LSP-AI-PLAN.md) | First opt-in provider slice and privacy/safety gate. | | [Activity panel](plans/EXTENSION-ACTIVITY-PANEL-PLAN.md) | Settings wiring and remaining cross-editor/test quality. | | [Type narrowing and inference](plans/CHECKER-TYPE-NARROWING-INFERENCE-PLAN.md) | Bidirectional/constraint-based inference engine, flow analysis, shared subtyping, and PEP 827 readiness. | -| [Runtime typeshed acquisition](plans/CHECKER-TYPESHED-RUNTIME-PLAN.md) | Acquisition, source precedence, startup gating, and #288/#289 hovers all shipped; only the per-artifact licensing gate remains (composite LICENSE, conditional NOTICE files, and modified-file marks verified inside every binary, wheel, and VSIX). | +| [Runtime typeshed resolution](plans/CHECKER-TYPESHED-RUNTIME-PLAN.md) | Two open items: a socket-instrumented witness that checking is offline across CLI/LSP/MCP, and byte-exact per-artifact licensing verification inside the VSIX (binaries and wheels are already verified). | | [Eliminate line scanning](plans/CHECKER-ELIMINATE-LINE-SCANNING-PLAN.md) | Replace remaining raw-source rule scans with AST data. | | [Advanced checker features](plans/CHECKER-ADVANCED-FEATURES-PLAN.md) | Mojo checks, plugin host, migration, and CI helpers. | | [Native compiler](plans/COMPILER-ARCHITECTURE-PLAN.md) | HIR, backend, runtime, interop, CLI, and native acceptance. | diff --git a/docs/plans/CHECKER-TYPESHED-RUNTIME-PLAN.md b/docs/plans/CHECKER-TYPESHED-RUNTIME-PLAN.md index 7487414c..81b3eda9 100644 --- a/docs/plans/CHECKER-TYPESHED-RUNTIME-PLAN.md +++ b/docs/plans/CHECKER-TYPESHED-RUNTIME-PLAN.md @@ -1,125 +1,141 @@ -# [TYPESHEDRT-OVERVIEW] Runtime typeshed acquisition — Implementation Plan {#TYPESHEDRT-OVERVIEW} +# Runtime typeshed acquisition — Implementation Plan {#TYPESHEDRT-OVERVIEW} > **Normative spec**: [STUBRES-TYPESHED](../specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED) > **Pinned typing authority**: [`python/typing@6ef9f7719ecfff09dad8724ef42b621fd994fb5e`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst) This supplies the real standard-library `.pyi` bodies missing in [#324](https://github.com/Nimblesite/Basilisk/issues/324), so [#289](https://github.com/Nimblesite/Basilisk/issues/289) and [#288](https://github.com/Nimblesite/Basilisk/issues/288) can be fixed — offline and online alike — without changing the typing specification's resolution order. -## [TYPESHEDRT-MODEL] Contract {#TYPESHEDRT-MODEL} +**Seventeen acceptance items remain open**, all in +[§TYPESHEDRT-ACCEPTANCE](#TYPESHEDRT-ACCEPTANCE): two on source acquisition and +identity, seven on offline verification and the store, four on explicit user +sources, and four on licensing and release gates. Each is an independent +automated test that does not exist yet; the surrounding prose is settled contract +retained because the implementation cites its anchor. + +## Contract {#TYPESHEDRT-MODEL} Pinned step 3 says **“Typeshed stubs for the standard library”**, says those stubs are **“usually”** vendored, and says a provided custom path **“SHOULD [be used] as the canonical source for standard-library types in this step”** ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). “Usually” mandates neither bundling nor any Git policy; the transport below is Basilisk policy where the specification is silent. -Basilisk activates one complete source: Custom folder; Exact commit (fail closed -unless the bundle has that SHA); or Latest with a loudly warned bundled fallback. -It never clones, mixes sources, reuses old unpinned data, maps Python versions to -commits, or changes an exact identity. Latest -warns `UNPINNED` and **Pin current** makes determinism one action away. Custom -is user-managed. +Basilisk activates one complete source, always already on this machine: a +**pinned commit** (the embedded bundle when the SHA is the bundled one, else that +commit's store entry) or a **custom folder**. Both fail closed. It never clones, +mixes sources, maps Python versions to commits, or changes an exact identity. +Custom is user-managed. -## [TYPESHEDRT-WORK] Work {#TYPESHEDRT-WORK} +**The checker never downloads** ([§STUBRES-TYPESHED-OFFLINE](../specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-OFFLINE)). +Acquisition is a segregated component a person invokes +([§STUBRES-TYPESHED-DOWNLOAD](../specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-DOWNLOAD)); +**Download latest** acquires `main` and writes that SHA as the pin. -The pinned order puts standard-library typeshed at step 3, stub packages at step 4, inline `py.typed` packages at step 5, and optional vendored third-party stubs last ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). Implement only what preserves that order: +## Work {#TYPESHEDRT-WORK} -1. One identity supplies module names, `VERSIONS`, real `.pyi` bodies, and derived indexes. -2. Resolve trusted commit→tree metadata, stream a safe archive through shape, approved-license/NOTICE, and Git-tree gates, then cache the immutable ZIP and read it through the same VFS. Only content hashing is disableable. -3. Downloaded cached ZIP bytes are re-hashed on every reuse. Exact pins are reused - regardless of age; Latest expires after 24 hours. Explicit eviction reacquires, - while cache-off downloads, validates, and discards. A custom miss proceeds to step 4. -4. Gate analysis, fingerprint caches by source identity, and return active source/full SHA plus composable `UNPINNED`, fallback, `LICENSE CHANGED`, `UNVERIFIED`, and user-managed statuses on CLI/LSP/MCP ([§STUBRES-TYPESHED-WARN](../specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN)). +The pinned order puts standard-library typeshed at step 3, stub packages at step 4, inline `py.typed` packages at step 5, and optional vendored third-party stubs last ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). Only what preserves that order is in scope: -## [TYPESHEDRT-ACCEPTANCE] Acceptance criteria {#TYPESHEDRT-ACCEPTANCE} +1. One identity supplies module names, `VERSIONS`, real `.pyi` bodies, and derived indexes. +2. **Segregate transport into its own crate** so the crate the checker links + against carries no HTTP dependency and the analysis path cannot reach the + network ([§TYPESHEDRT-SEGREGATION](#TYPESHEDRT-SEGREGATION)). +3. The download component resolves trusted commit→tree metadata, streams a safe + archive through the safety, shape, approved-license/NOTICE, and Git-tree + gates, reconstructs the commit object and asserts it hashes to the requested + SHA, then dumps the accepted tree, that commit object, and a manifest into the + store. Failure writes nothing. +4. The checker resolves a pin from the store or the bundle and verifies it + offline by hashing; missing or corrupt fails hard. A custom miss proceeds to step 4. +5. Fingerprint caches by source identity, and return active source/full SHA plus + composable `UNPINNED`, `LICENSE CHANGED`, `USER-MANAGED SOURCE`, and `NO SOURCE` + statuses on CLI/LSP/MCP ([§STUBRES-TYPESHED-WARN](../specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN)). + +## Segregation {#TYPESHEDRT-SEGREGATION} + +| Crate | May link an HTTP client | Role | +|---|---|---| +| `basilisk-stubs` | **no** | resolve, verify, and read a local source; owns the gates, codec, gittree, archive VFS, bundle, store reader | +| `basilisk-typeshed-fetch` | yes | the only typeshed network code: metadata, download, gates, store writer | +| `basilisk-checker` | **no** | depends on `basilisk-stubs` only; the fetch crate is not in its dependency graph | +| `basilisk-cli`, `basilisk-lsp` | yes | depend on both, and invoke the fetch crate only from an explicit user action | + +`scripts/check-dependency-shape.sh` asserts that shape from the resolved `cargo +tree` graphs and runs inside `make lint`. It is what makes "the checker never +downloads" a property of the build rather than a promise in prose. + +## Acceptance criteria {#TYPESHEDRT-ACCEPTANCE} + +Every remaining checkbox is an independent automated test. The pinned specification says type checkers **“SHOULD resolve modules containing type information”** in its listed order ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)); the acquisition mechanics below are Basilisk policy where that specification is silent. + +### Source acquisition and identity {#TYPESHEDRT-ACCEPTANCE-SOURCE} -Each checkbox is an independent automated test. The pinned specification says type checkers **“SHOULD resolve modules containing type information”** in its listed order ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)); the acquisition mechanics below are Basilisk policy where that specification is silent. +Step 3 identifies **“Typeshed stubs for the standard library”**, while the same pinned text does not prescribe transport, cache age, or commit selection ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). -### [TYPESHEDRT-ACCEPTANCE-SOURCE] Source acquisition and identity {#TYPESHEDRT-ACCEPTANCE-SOURCE} +Acquisition never invokes `git` or a Git transport; one activated generation +answers every lookup with no fallback; the activation gates reject malformed +shape/tree/license metadata and hostile archive entries even with content +verification off; a cached ZIP that was mutated never activates; concurrent +CLI/LSP/MCP callers observe one atomic promotion; and the checker cache +fingerprint keys on source identity. A pin with no store entry and no bundle SHA +match, and a custom folder that does not exist, both refuse to analyse, name the +missing SHA or path, emit `NO SOURCE`, and substitute nothing. -Step 3 identifies **“Typeshed stubs for the standard library”**, while the same pinned text does not prescribe transport, cache age, or commit selection ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). +- [ ] **Checking is offline.** Instrument every socket; run `basilisk check`, the LSP over a workspace, and MCP against a pin, a custom folder, and the bundle, and assert **zero** network calls in every case — including when the pin is missing. The structural half of this is already enforced by `scripts/check-dependency-shape.sh`; what is missing is the end-to-end socket witness over the surfaces that *do* link the fetch crate. -- [x] **Never clones.** Instrument process spawns and network calls; assert acquisition issues an HTTPS archive fetch and **never** invokes `git`, `git clone`, or a Git transport. -- [x] **Current unpinned `main`:** seed cached SHA `A`, advertise remote SHA `B`, acquire, and assert the reported SHA, `.pyi` path, `stdlib/VERSIONS`, module names, and distribution map all come from `B`. -- [x] **One generation:** give `A`, `B`, and the bundled snapshot conflicting sentinels; after activating `B`, assert every name/body/distribution lookup reads `B` and no fallback lookup occurs. -- [x] **Failure rules:** cached unpinned `A` plus failed Latest selects the real-body bundled ZIP and warnings, never `A`; unavailable exact pin fails closed unless the bundle SHA equals it. -- [x] **Activation gates:** corrupt shape/tree/license metadata; add duplicate, absolute/`..`, escaping-link, over-count, and zip-bomb entries; assert rejection even with content verification off. -- [x] **Immutable ZIP/VFS:** interrupt acquisition and mutate a cached ZIP; assert neither activates, reuse detects mutation by ZIP SHA-256, and every `.pyi` read comes from the accepted ZIP. -- [x] **Concurrent callers:** CLI/LSP/MCP callers observe one complete identity and one atomic promotion. -- [x] **Cache fingerprint:** different SHAs, custom-tree identities, and bundled identity miss the checker cache; identical identities hit it. - -### [TYPESHEDRT-ACCEPTANCE-VERIFY] Integrity, verification, and cache {#TYPESHEDRT-ACCEPTANCE-VERIFY} - -Trusted GitHub metadata binds commit to tree; a user pin selects the commit, and -Git-tree verification binds VFS-consumed bytes to that tree -([§STUBRES-TYPESHED-ACQUIRE](../specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE)). - -- [x] **Tree binding:** two archive encodings of one tree pass and any content mutation fails; a pin alone proves nothing because Git commits identify trees, not ZIP hashes ([Git `commit-tree`](https://git-scm.com/docs/git-commit-tree)); verified metadata reports only its GitHub/TLS trust boundary, not a signed typeshed release. -- [x] **Cache controls:** reuse always re-hashes downloaded bytes; an exact pin has - no age expiry, while Latest expires after 24 hours; explicit eviction reacquires; - cache-off leaves no ZIP; - verification-on reruns the content gate before reporting verified. -- [x] **Verification waived:** skip only tree hashing; safety, shape, approved-license/NOTICE checks still run; all surfaces report `UNVERIFIED` without implying verified provenance. -- [x] **License drift:** change the approved path+SHA-256 manifest for any relevant root/nested `LICENSE*`/`NOTICE*` on Latest, pin, and mirror paths; block, report `LICENSE CHANGED`, and use bundled only under Latest rules. -- [x] **Mirror:** known SHA downloads through `{sha}` and verifies; Latest without official metadata cannot reuse an earlier SHA and falls back loudly. -- [x] **Status routing:** compose `UNPINNED` + fallback + `UNVERIFIED`; assert CLI uses stderr, LSP uses `showMessage`/Service Info (not `publishDiagnostics`), and MCP returns the same ordered structured warnings. - -### [TYPESHEDRT-ACCEPTANCE-OVERRIDES] Explicit user sources {#TYPESHEDRT-ACCEPTANCE-OVERRIDES} +### Explicit user sources {#TYPESHEDRT-ACCEPTANCE-OVERRIDES} Pinned step 3 says a supplied custom typeshed **“SHOULD [be used] as the canonical source for standard-library types in this step”** ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). -- [x] **Exact commit:** configure full SHA `A`; assert exact tree/VFS bytes, later `main` movement has no effect, and unavailable `A` never substitutes another bundled SHA. -- [x] **Pinned reuse:** validate `A` and reuse its re-hashed downloaded ZIP regardless - of age; only explicit eviction or cache-off ends reuse. The pin never expires or changes. -- [x] **Pin current:** in Latest mode, resolve `main` to `B`, invoke **Pin current**, and assert `typeshed-commit` is written to `B`; repeat offline and assert it writes the *bundled snapshot* SHA. -- [x] **Not-pinned advisory:** fresh Latest, bundled fallback, and Custom all report `UNPINNED`; only explicit `typeshed-commit` suppresses it; status never becomes a Python diagnostic. -- [x] **Custom tree:** conflicting custom/download/bundle data resolves custom verbatim, reports user-managed terms without assuming Apache/MIT, and bypasses every other step-3 lookup. -- [x] **Custom miss:** omit `X` only from custom while putting it in download/bundled; assert resolution goes directly to step 4 and never rescues `X` from another step-3 source. -- [x] **Configuration validation:** cover wrong key types, source conflicts, - absolute/workspace-relative paths, required `stdlib/`, nonexistent paths, - malformed trees, and deterministic fail-closed errors. +A full SHA selects exact tree/VFS bytes and later `main` movement has no effect. +A store entry for that commit is reused regardless of age, re-verified by hashing +every time, and only deletion ends reuse — the pin never expires or changes. +**Download latest** lands the resolved commit in the store and pegs it as +`typeshed-commit` in one action, writing neither when it fails; **Download +pinned** materialises an existing pin and leaves the configuration untouched. The +bundled default and Custom report `UNPINNED`, and a custom tree resolves verbatim +under user-managed terms, bypassing every other step-3 lookup — a custom miss +goes straight to step 4. -### [TYPESHEDRT-ACCEPTANCE-TARGET] Python target semantics {#TYPESHEDRT-ACCEPTANCE-TARGET} +### Python target semantics {#TYPESHEDRT-ACCEPTANCE-TARGET} The pinned stub specification says checkers should fully support **“Simple version and platform checks”**; its directives say checkers are **“expected to understand simple version and platform checks”** using `sys.version_info` and `sys.platform` ([distributing](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst), [directives](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/directives.rst), both `python/typing@6ef9f77`). -- [x] **Same SHA, different targets:** run two target versions against one SHA; assert acquisition identity is unchanged while `stdlib/VERSIONS` admits the fixture's target-specific modules. -- [x] **Guard selection:** concrete target selects one version/platform branch; `All` requires validity across alternatives and never exposes a one-platform-only name (#318 regression). -- [x] **Target environment:** cross-version and multi-root checking use each owning root's target interpreter `site-packages`/`dist-packages`, including an explicit Python-binary override; no root may inherit another root's packages. -- [x] **No commit inference:** instrument the network and change only `python-version`/`python-platform`; assert no different SHA is selected, guessed, or fetched. -- [x] **No manufactured target:** assert configuration, generated data, and bundled data contain no Python-version-to-SHA map and no fixed Python target appears without project/interpreter evidence. +The Python target filters `stdlib/VERSIONS` and version/platform guards; it never +selects, guesses, or fetches a commit. A concrete target picks one branch, `All` +requires validity across alternatives, each root uses its own interpreter's +packages, and no fixed Python target appears without project or interpreter +evidence. -### [TYPESHEDRT-ACCEPTANCE-RESOLUTION] Resolution and stub semantics {#TYPESHEDRT-ACCEPTANCE-RESOLUTION} +### Resolution and stub semantics {#TYPESHEDRT-ACCEPTANCE-RESOLUTION} The pinned specification orders manual stubs, user code, stdlib typeshed, stub packages, inline `py.typed`, and optional vendored third-party stubs; it also says checkers **“MUST maintain the normal resolution order of checking `*.pyi` before `*.py` files”** ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). -- [x] **Six steps:** collide `X` at steps 1–5, remove each winner, then assert step 6's deliberate absence and unresolved; separately prove installed untyped `.py` resolves as untyped. -- [x] **Stub package versus inline:** install `foopkg-stubs` beside inline `py.typed` `foopkg`; assert step 4 wins over step 5. -- [x] **Package misses:** complete stub-package miss stops; `partial\n` and stub-only namespace (no `__init__.pyi`) misses continue to steps 5/6. -- [x] **`.pyi` precedence:** place `.pyi` and `.py` for one module at the winning location; assert only `.pyi` supplies the public interface. -- [x] **#312/#318 exports:** with an exact MicroPython snapshot, `import - asyncio` exposes `asyncio.sleep`, `asyncio.Task`, and `asyncio.run` through the - production module binding; redundant aliases, specified `__all__` mutations, - stars, private exclusion, cycles, and long chains resolve without target unions. +All six steps are honoured in order, step 4 beats step 5, partial and stub-only +namespace misses fall through, `.pyi` supplies the public interface wherever a +`.py` sits beside it, and re-export chains resolve without target unions. -### [TYPESHEDRT-ACCEPTANCE-HOVER] #288 and #289 behavior {#TYPESHEDRT-ACCEPTANCE-HOVER} +### #288 and #289 behavior {#TYPESHEDRT-ACCEPTANCE-HOVER} Pinned stub and constructor rules govern these tests ([distributing](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst), [constructors](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/constructors.rst), both `python/typing@6ef9f77`). -- [x] **#289:** real `unittest.mock.Mock` plus fixtures cover special metaclass `__call__`, inherited non-`object` `__new__`/`__init__`, object fallback, binding, overloads/unions, and non-instance termination. -- [x] **#288:** real `str.join` preserves overloads, return types, `LiteralString`, `/`, and receiver specialization/removal in hover and call checking; `.pyi` mutation proves no hand table. -- [x] **Offline parity:** repeat #288/#289 on the **bundled ZIP** (network removed) and assert identical real-body signatures — the offline floor is not names-only. -- [x] **Override behavior:** repeat both with conflicting custom stubs and assert custom signatures/provenance. -- [x] **Shared declaration:** assert hover, signature help, completion, and go-to-definition use the same indexed declaration and source identity. -### [TYPESHEDRT-ACCEPTANCE-GATES] Licensing and release gates {#TYPESHEDRT-ACCEPTANCE-GATES} +Constructor and method signatures come from the real `.pyi` bodies — never a hand +table — on the bundled ZIP as well as online, custom stubs win where supplied, +and hover, signature help, completion, and go-to-definition all read the same +indexed declaration and source identity. + +### Licensing and release gates {#TYPESHEDRT-ACCEPTANCE-GATES} Bundling invokes Apache 2.0 §4; runtime downloads do not make Basilisk the redistributor ([§STUBRES-TYPESHED-LICENSE](../specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-LICENSE)). -- [ ] **Every artifact:** exact bundled-SHA composite LICENSE (including MIT notice), conditional root/nested NOTICE/license files, retained notices, and modified-file marks ship in every binary/package/VSIX. -- [x] **Policy metadata:** `THIRD-PARTY-LICENSES`/`NOTICES` record typeshed, licenses, URL, exact SHA, derived indexes, and repackaging; any license identity/NOTICE change fails for human review. -- [x] **MCP provenance:** structured status includes active source, full commit/tree identity, transport, license status/reference (custom may say `not supplied`), and ordered warnings. -- [x] **Conformance:** run the unmodified `python/typing@main` conformance harness against the clean release binary; require 100% and zero false positives, including no source-status diagnostics. -- [x] **Docs integrity:** validate the six-step Mermaid flow, anchors, links, and the full `6ef9f7719ecfff09dad8724ef42b621fd994fb5e` pin in every touched typeshed section. -- [x] **No forbidden policy:** reject stale unpinned fallback, Python-version-to-SHA - maps, fixed Python defaults, `git clone`, and indefinite downloaded-byte reuse; - preserve exact immutable pins, re-hashed cached bytes, and custom paths. +`THIRD-PARTY-LICENSES`/`NOTICES` record typeshed, its licenses, URL, exact SHA, +derived indexes, and repackaging, and any license-identity or NOTICE change fails +for human review; MCP status carries active source, full commit/tree identity, +license status/reference, and ordered warnings with no separate provenance field; +the forbidden-policy guard rejects analysis-path network calls, automatic +downloads, unnamed-SHA fallbacks, verification waivers, Python-version-to-SHA +maps, fixed Python defaults, and `git clone`; the unmodified `python/typing@main` +harness passes at 100% with zero false positives against the clean release +binary; and the documentation integrity gate validates the six-step flow, +anchors, links, and the pin. + +- [ ] **Every artifact:** exact bundled-SHA composite LICENSE (including MIT notice), conditional root/nested NOTICE/license files, retained notices, and modified-file marks ship in every binary/package/VSIX. `scripts/verify_release_attribution.py` verifies the binary archives (`--kind binary`) and the wheels (`--kind wheel`) byte-exactly; the VSIX is still only name-presence-checked by `unzip -l`, so it needs the same exact-content verification. diff --git a/docs/plans/LSP-AI-PLAN.md b/docs/plans/LSP-AI-PLAN.md index 3042d103..a999711c 100644 --- a/docs/plans/LSP-AI-PLAN.md +++ b/docs/plans/LSP-AI-PLAN.md @@ -45,9 +45,9 @@ it must not reuse an unstructured prompt as an implicit API. ([CHECKER-STUB-RESOLUTION-SPEC §STUBRES-TYPESHED-WARN](../specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN)). - [ ] Every AI result is visibly provider-originated and unsafe until reviewed. - [ ] No **AI-provider** network request occurs unless a user enables a provider — no provider is - contacted in the default configuration or in deterministic test suites. (The default typeshed - archive download is a separate, expected default network operation; tests select an explicit `typeshed-commit` or - run against the bundled stdlib ZIP — - [CHECKER-STUB-RESOLUTION-SPEC §STUBRES-TYPESHED](../specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED), + contacted in the default configuration or in deterministic test suites. (Nothing else contacts the + network either: typeshed resolution is offline by construction, so a provider request is the ONLY + network traffic an enabled provider can introduce — + [CHECKER-STUB-RESOLUTION-SPEC §STUBRES-TYPESHED-OFFLINE](../specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-OFFLINE), applying pinned typing step 3 at [`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst).) - [ ] Cancellation prevents stale edits from being offered after the document changes. diff --git a/docs/plans/SPEC-CONFORMANCE-AUDIT-PLAN.md b/docs/plans/SPEC-CONFORMANCE-AUDIT-PLAN.md index cec5f710..fcfddd11 100644 --- a/docs/plans/SPEC-CONFORMANCE-AUDIT-PLAN.md +++ b/docs/plans/SPEC-CONFORMANCE-AUDIT-PLAN.md @@ -30,7 +30,7 @@ requires a failing behavior test before the implementation change. | Spec ID | Current deviation | Location | |---|---|---| -| `CHKARCH-CLI-EXITCODES` | Malformed configuration falls back to defaults, so configuration exit code `2` is never produced. | `basilisk-cli/src/main.rs`, `basilisk-config/src/lib.rs` | +| `CHKARCH-CLI-EXITCODES` | A `pyproject.toml` that fails to parse is silently discarded and the run falls back to defaults, so that flavour of invalid configuration never reaches exit code `2`. (A `pep` rule resolved to `disabled` does exit `2` — `pipeline/mod.rs` raises `PipelineError::Config` and `main.rs` maps it.) | `basilisk-config/src/lib.rs`, `basilisk-config/src/parse.rs` | ## Verification {#CONFAUDIT-VERIFICATION} diff --git a/docs/readme/README.src.md b/docs/readme/README.src.md new file mode 100644 index 00000000..d4ccbc3d --- /dev/null +++ b/docs/readme/README.src.md @@ -0,0 +1,186 @@ + +

+ Basilisk +

+ +

Basilisk

+ +

English · 简体中文

+ +

+ The only Python type checker scoring 100% on the official python/typing conformance suite — and the fastest we’ve measured.
+ Complete open-source Python dev environment in Rust: type checker, language server, debugger, profiler, plus VS Code, Cursor, Zed & Neovim extensions. Strict by default. + Weighing up the best Python type checker for your codebase? Start with the scoreboard. +

+ + +> **You are reading the Basilisk source repository** — the checker, language server, editor extensions, and website all live here. + + +> **You are reading the Basilisk extension listing** for VS Code, Cursor, Windsurf, and every VS Code fork — the same extension is published to the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) and [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk). + + +> **You are reading the `basilisk-python` wheel listing** — the Basilisk CLI packaged for `pip`/`uv`. The distribution is named `basilisk-python` because `basilisk` was taken on PyPI; the installed command is still `basilisk`. + + +

+ Website  •  + Install  •  + Quick Start  •  + Rules  •  + Refactoring  •  + Compare  •  + GitHub +

+ +

+ 100.0% PEP conformance141 of 141 tests in the official + python/typing + conformance suite (commit 6ef9f77), scored on the wheel-installed CLI in its default config by the real upstream harness. + We target python/typing@main and ratchet the score up only. +

+ +## The only 100% checker — and the fastest according to our benchmarks + +Basilisk is the **only** Python type checker with a perfect score on the official +[`python/typing` conformance suite](https://github.com/python/typing/blob/main/conformance/results/results.html): +**100.0%** (141/141 files, 970 required errors caught, 0 false positives), +measured by the real upstream harness on the wheel-installed CLI in its default config. + +

+ Basilisk in action — type checking, diagnostics, and refactoring in the editor +

+ +And it is the **fastest checker we’ve measured** — median cold full-file check, from scratch: + +| Type checker | Median cold check | +| --- | --- | +| ⚡ **Basilisk** | **10 ms** | +| zuban | 28 ms | +| ty | 40 ms | +| Pyrefly | 110 ms | +| Pyright | 573 ms | +| mypy | 574 ms | + +Median cold full-file check across 26 single-construct typing-spec stress fixtures on an Apple M4 Max — lower is better. Basilisk’s warm re-check drops to ~4 ms. Every figure is produced by [`hyperfine`](https://github.com/sharkdp/hyperfine) and committed per machine, so nothing here is hand-typed. **Clone the repo, run `make bench` on your own hardware, and send us the CSV — independent audits are welcome.** [Full benchmarks & methodology →](https://www.basilisk-python.dev/docs/benchmarks/) + +## Everything in one extension + +One extension replaces Pylance and gives you the whole workflow — no Node.js, no Python runtime, no pip, no npm. A single bundled Rust binary drives it all: + +- **Strict-by-default diagnostics** — inline as you type, incremental analysis powered by Salsa (the rust-analyzer engine) +- **Autocomplete, hover, go-to-definition, find references, rename** +- **Refactoring code actions** — extract, inline, move symbol, organize imports +- **Integrated debugging** — F5 to debug via bundled debugpy; no separate extension +- **Integrated profiling** — CPU heat map, flame graph, and a memory dashboard with leak detection +- **Activity panel** — module tree with per-module type-health coverage, plus feature toggles +- **Inlay hints** and **Ruff** formatting/import-organization, built in +- **Standard-library types from [typeshed](https://github.com/python/typeshed)** — a complete `stdlib/` snapshot is compiled into the binary, so hover and diagnostics work offline with no configuration + +Every diagnostic teaches: rustc-style output with a `help`, a `note`, and a link to a per-rule explainer, so a red squiggle always tells you *why*. Basilisk **starts strict** and stays strict — the unconfigured default enables the complete typing-spec rule set, and strictness is dialled per rule, never by a mode. + +## Install + +**Editor extension** — install *Basilisk* from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) or [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk) (Cursor, Windsurf, and other forks read Open VSX). The Basilisk binary is bundled for macOS (Apple Silicon), Linux (x86_64, aarch64), and Windows (x86_64, aarch64) — nothing else to install. Zed and Neovim 0.10+ extensions are available too. + +**CLI** — on [PyPI as `basilisk-python`](https://pypi.org/project/basilisk-python/); the installed command is `basilisk`: + +```sh +uv tool install basilisk-python # or: pipx install basilisk-python, pip install basilisk-python +``` + +Also via Homebrew (`brew install Nimblesite/tap/basilisk`), Scoop (`scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket && scoop install basilisk`), and [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases). Every channel ships the same single Rust CLI, built from this repository at the same version, with no runtime dependencies. Point `basilisk.executablePath` at your own build to have the extension use it. Full options: [install guide](https://www.basilisk-python.dev/docs/installation/). + +## Try it + +The [`examples/`](examples/) folder has ready-to-go Python files: + +```sh +basilisk check examples/bad.py # 8 typing-spec errors — always on, no config needed +basilisk analyze examples/bad.py # the opt-in strictness warnings on the same file +basilisk analyze examples/good.py # clean, even at full strictness +basilisk check examples/mixed.py # one real type error +basilisk check examples/ # the whole folder at once +``` + +Machine-readable output for CI and tooling: + +```sh +basilisk check path/to/your_code.py --output json --color never +``` + +The two commands read one rule universe split by provenance ([`CHKARCH-COMMANDS`](docs/specs/CHECKER-ARCHITECTURE-SPEC.md)): `check` reports +the `pep`-tagged typing-spec rules and nothing else — that set is always on, and +while a config table may grade one of them down to `warning`/`info`, none may +switch it off. `analyze` reports the non-`pep` house rules, which stay silent +until a table selects them. Only `analyze` emits `BSK-` diagnostics. + +## Standard-library types, always offline + +Basilisk resolves the standard library from [typeshed](https://github.com/python/typeshed), +and checking **never downloads anything**. Out of the box it uses the complete +typeshed `stdlib/` snapshot compiled into the binary, reporting the source as +unpinned — so stdlib types work on a plane, behind a firewall, or in an +air-gapped CI runner, with no configuration. + +Pin an exact commit with `typeshed-commit = "<40-char sha>"` under +`[tool.basilisk]`. A pin does exactly one thing: it verifies, offline, that the +typeshed tree in the local store hashes to that commit. If the commit is not on +this machine the run fails hard with `NO SOURCE` rather than substituting +another source — bring it down first with `basilisk typeshed download` (with no +`--commit` it downloads the latest and writes the pin for you), or use the +editor's **Download latest** button. Alternatively, point `typeshed-path` at +your own typeshed tree. Full options: +[configuration guide](https://www.basilisk-python.dev/docs/configuration/). + +## Development + +```sh +cargo build # build all crates +cargo test # run all tests +cargo clippy # lint (zero warnings policy) +cargo fmt # format +``` + +Rust 1.87+ required. + +## Contributing + +Basilisk is built by a human + AI partnership, with the work split on purpose. See +[CONTRIBUTING.md](CONTRIBUTING.md) — **For Humans** (testing, code-quality review, +conformance/security audits, IDE feature parity, sharpening the AI instructions) and +**For AI** (the technical execution, under the standing rules in [CLAUDE.md](CLAUDE.md)). + +## Acknowledgments + +Basilisk builds on the open-source community — with thanks to: + +- **[Astral](https://astral.sh/)** — [Ruff](https://github.com/astral-sh/ruff), whose parser, AST, and formatter crates Basilisk embeds (MIT). The foundation we rely on most. +- **[typeshed](https://github.com/python/typeshed)** — standard-library type stubs (Apache-2.0, with MIT-licensed parts). +- **[Salsa](https://github.com/salsa-rs/salsa)** — incremental query engine. +- **[Rayon](https://github.com/rayon-rs/rayon)** — data parallelism. +- **[tower-lsp](https://github.com/ebkalderon/tower-lsp)** — LSP scaffolding. +- **[debugpy](https://github.com/microsoft/debugpy)** — debug adapter (bundled in the VS Code extension). +- The [`python/typing`](https://github.com/python/typing) conformance suite. + +Full component list, selected licenses, and required notices: [NOTICES](NOTICES) +and [RUST-DEPENDENCY-LICENSES](RUST-DEPENDENCY-LICENSES). Each published +artifact carries its own copies: the VSIX ships Rust notices in +`RUST-DEPENDENCY-LICENSES`, npm notices in `VSCODE-DEPENDENCY-LICENSES`, and +debugpy's license and `ThirdPartyNotices.txt` inside `bundled/debugpy`; the +wheel carries the complete locked notices in its `.dist-info/licenses/` +directory. + +--- + +## License + +Basilisk source code is MIT licensed. Binary distributions also contain +third-party components under the licenses shipped beside each artifact. + +Built by [NIMBLESITE PTY LTD](https://www.nimblesite.co). diff --git a/docs/readme/README.zh.src.md b/docs/readme/README.zh.src.md new file mode 100644 index 00000000..6efd7390 --- /dev/null +++ b/docs/readme/README.zh.src.md @@ -0,0 +1,177 @@ + +

+ Basilisk +

+ +

Basilisk

+ +

English · 简体中文

+ +

+ 唯一在官方 python/typing 一致性测试套件上取得 100% 的 Python 类型检查器 —— 也是我们测得最快的。
+ 用 Rust 打造的完整开源 Python 开发环境:类型检查器、语言服务器、调试器、性能分析器,以及 VS Code、Cursor、Zed 与 Neovim 扩展。默认严格。 +

+ + +> **你正在阅读 Basilisk 的源码仓库** —— 检查器、语言服务器、编辑器扩展与网站都在这里。 + + +> **你正在阅读 Basilisk 的扩展页面**,适用于 VS Code、Cursor、Windsurf 以及所有 VS Code 分支 —— 同一个扩展同时发布到 [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) 与 [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk)。 + + +

+ 网站  •  + 安装  •  + 快速上手  •  + 规则  •  + 重构  •  + 对比  •  + GitHub +

+ +

+ PEP 一致性 100.0% — 官方 + python/typing + 一致性套件(提交 6ef9f77141 项测试中通过 141 项, + 由真实的上游评分器在默认配置下对 wheel 安装的 CLI 评出。 + 我们以 python/typing@main 为目标,且分数只升不降。 +

+ +## 唯一 100% 的检查器 — 按我们的基准测试也是最快的 + +Basilisk 是**唯一**在官方 +[`python/typing` 一致性套件](https://github.com/python/typing/blob/main/conformance/results/results.html) +上取得满分的 Python 类型检查器:**100.0%** +(141/141 个文件,捕获 970 处必需错误,0 个误报), +由真实的上游评分器在默认配置下对 wheel 安装的 CLI 测得。 + +

+ Basilisk 实战 —— 编辑器中的类型检查、诊断与重构 +

+ +它也是**我们测得最快的检查器** — 从零开始的整文件冷检查中位数: + +| 类型检查器 | 冷检查中位数 | +| --- | --- | +| ⚡ **Basilisk** | **10 ms** | +| zuban | 28 ms | +| ty | 40 ms | +| Pyrefly | 110 ms | +| Pyright | 573 ms | +| mypy | 574 ms | + +在 Apple M4 Max 上对 26 个单一构造的类型规范压力用例测得的整文件冷检查中位数 — 越低越好。Basilisk 的热重检查可降至约 4 ms。每个数字都由 [`hyperfine`](https://github.com/sharkdp/hyperfine) 产生并按机器提交,没有一个是手写的。**克隆仓库,在你自己的硬件上运行 `make bench`,并把 CSV 发给我们 — 欢迎独立复核。** [完整基准与方法论 →](https://www.basilisk-python.dev/zh/docs/benchmarks/) + +## 一个扩展,覆盖全部 + +一个扩展即可取代 Pylance 并提供完整工作流 —— 无需 Node.js、无需 Python 运行时、无需 pip、无需 npm。一切由单一捆绑的 Rust 二进制文件驱动: + +- **默认严格的诊断** —— 随输入实时呈现,由 Salsa(rust-analyzer 的引擎)提供增量分析 +- **自动补全、悬停信息、跳转到定义、查找引用、重命名** +- **重构代码操作** —— 提取、内联、移动符号、整理导入 +- **集成调试** —— 按 F5 即可通过捆绑的 debugpy 调试;无需额外扩展 +- **集成性能分析** —— CPU 热力图、火焰图,以及带泄漏检测的内存面板 +- **活动面板** —— 模块树与逐模块的类型健康度覆盖率,并可切换功能开关 +- 内置 **Inlay hints** 与 **Ruff** 格式化/导入整理 +- **来自 [typeshed](https://github.com/python/typeshed) 的标准库类型** —— 完整的 `stdlib/` 快照已编译进二进制文件,因此悬停与诊断在离线且零配置的情况下依然可用 + +每条诊断都有教育意义:rustc 风格的输出,附带 `help`、`note` 以及指向每条规则详解页的链接,因此一条红色波浪线总能告诉你*为什么*。Basilisk **一开始就严格**并始终严格 —— 未配置的默认值即启用完整的类型规范规则集,严格程度按规则微调,而不是靠模式切换。 + +## 安装 + +**编辑器扩展** —— 从 [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) 或 [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk) 安装 *Basilisk*(Cursor、Windsurf 等分支读取 Open VSX)。Basilisk 二进制文件已为 macOS(Apple Silicon)、Linux(x86_64、aarch64)与 Windows(x86_64、aarch64)捆绑 —— 无需再安装其他东西。Zed 与 Neovim 0.10+ 的扩展同样可用。 + +**命令行工具** —— 在 [PyPI 上名为 `basilisk-python`](https://pypi.org/project/basilisk-python/);安装后的命令是 `basilisk`: + +```sh +uv tool install basilisk-python # 或:pipx install basilisk-python、pip install basilisk-python +``` + +也可通过 Homebrew(`brew install Nimblesite/tap/basilisk`)、Scoop(`scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket && scoop install basilisk`)与 [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases) 获取。每个渠道都发布同一个 Rust 命令行工具,由本仓库在同一版本构建,且没有运行时依赖。把 `basilisk.executablePath` 指向你自己的构建,扩展就会使用它。完整选项:[安装指南](https://www.basilisk-python.dev/zh/docs/installation/)。 + +## 试一试 + +[`examples/`](examples/) 目录中有可直接运行的 Python 文件: + +```sh +basilisk check examples/bad.py # 8 处类型规范错误 —— 始终启用,无需配置 +basilisk analyze examples/bad.py # 同一文件上可选的严格性警告 +basilisk analyze examples/good.py # 即使在完全严格下也是干净的 +basilisk check examples/mixed.py # 一处真实的类型错误 +basilisk check examples/ # 一次检查整个目录 +``` + +供 CI 与工具使用的机器可读输出: + +```sh +basilisk check path/to/your_code.py --output json --color never +``` + +这两条命令读取的是按来源划分的同一套规则宇宙([`CHKARCH-COMMANDS`](docs/specs/CHECKER-ARCHITECTURE-SPEC.md)):`check` +只报告带 `pep` 标签的类型规范规则 —— 该集合始终启用,配置表虽可将其中某条 +降级为 `warning`/`info`,但都不能将其关闭。`analyze` 报告非 `pep` 的自有规则, +它们在被配置表选用之前始终保持沉默。只有 `analyze` 会输出 `BSK-` 诊断。 + +## 标准库类型:始终离线 + +Basilisk 从 [typeshed](https://github.com/python/typeshed) 解析标准库类型, +而且检查**从不下载任何东西**。开箱即用时它使用编译进二进制文件的完整 typeshed +`stdlib/` 快照,并将来源报告为未固定(unpinned)—— 因此在飞机上、防火墙后或 +隔离网络的 CI 中,标准库类型都无需配置即可使用。 + +在 `[tool.basilisk]` 中使用 `typeshed-commit = "<40 位 sha>"` 固定到某个确切提交。 +固定只做一件事:离线校验本地存储库中的 typeshed 树是否哈希为该提交。若该提交 +不在本机上,运行会以 `NO SOURCE` 硬失败,而不会替换为其他来源 —— 请先用 +`basilisk typeshed download` 取回(不带 `--commit` 时会下载最新提交并替你写入 +固定项),或使用编辑器中的 **Download latest** 按钮。或者,把 `typeshed-path` +指向你自己的 typeshed 目录树。完整选项参见[配置指南](https://www.basilisk-python.dev/zh/docs/configuration/)。 + +## 开发 + +```sh +cargo build # build all crates +cargo test # run all tests +cargo clippy # lint (zero warnings policy) +cargo fmt # format +``` + +需要 Rust 1.87+。 + +## 贡献 + +Basilisk 由人类与 AI 的协作打造,并有意地划分了各自的工作。请参阅 +[CONTRIBUTING.md](CONTRIBUTING.md) —— **For Humans**(测试、代码质量审查、 +一致性/安全审计、IDE 功能对等、打磨 AI 指令)以及 +**For AI**(在 [CLAUDE.md](CLAUDE.md) 既定规则下的技术执行)。 + +## 致谢 + +Basilisk 建立在开源社区之上 —— 特别感谢: + +- **[Astral](https://astral.sh/)** —— [Ruff](https://github.com/astral-sh/ruff),Basilisk 嵌入了其解析器、AST 与格式化器 crate(MIT)。我们最倚重的基础。 +- **[typeshed](https://github.com/python/typeshed)** —— 标准库类型存根(Apache-2.0,部分内容采用 MIT 许可证)。 +- **[Salsa](https://github.com/salsa-rs/salsa)** —— 增量查询引擎。 +- **[Rayon](https://github.com/rayon-rs/rayon)** —— 数据并行。 +- **[tower-lsp](https://github.com/ebkalderon/tower-lsp)** —— LSP 脚手架。 +- **[debugpy](https://github.com/microsoft/debugpy)** —— 调试适配器(捆绑于 VS Code 扩展)。 +- [`python/typing`](https://github.com/python/typing) 一致性测试套件。 + +完整的组件、所选许可证与必要声明见 [NOTICES](NOTICES) 和 +[RUST-DEPENDENCY-LICENSES](RUST-DEPENDENCY-LICENSES)。每个发布的产物也各自 +携带副本:VSIX 在 `RUST-DEPENDENCY-LICENSES` 中提供 Rust 声明,在 +`VSCODE-DEPENDENCY-LICENSES` 中提供 npm 声明,并在 `bundled/debugpy` 内保留 +debugpy 自身的许可证与 `ThirdPartyNotices.txt`;wheel 则在 `.dist-info/licenses/` +目录中携带完整的锁定声明。 + +--- + +## 许可证 + +Basilisk 源代码采用 MIT 许可证。二进制发行物还包含第三方组件;其许可证 +随每个发行物一并提供。 + +由 [NIMBLESITE PTY LTD](https://www.nimblesite.co) 构建。 diff --git a/docs/specs/CHECKER-ARCHITECTURE-SPEC.md b/docs/specs/CHECKER-ARCHITECTURE-SPEC.md index c3150dcc..9e547499 100644 --- a/docs/specs/CHECKER-ARCHITECTURE-SPEC.md +++ b/docs/specs/CHECKER-ARCHITECTURE-SPEC.md @@ -932,10 +932,10 @@ stub-resolution spec and `basilisk-stubs`. Pinned typing step 3 says a configured custom typeshed is the "canonical source" ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). Accordingly, `typeshed-path` is the sole step-3 tree when set. Otherwise Basilisk -uses an exact-SHA archive for an explicit `typeshed-commit` or current `main`, or -the bundled stdlib ZIP. Step-3 sources never mix; failed unpinned acquisition -never reuses an earlier archive. -`typeshed-cache-path` only relocates automatic storage, while `stub-paths` +resolves the pinned `typeshed-commit` (unset = the bundled commit) from the +local store or the bundled stdlib ZIP — always offline, never a download. +Step-3 sources never mix and a missing pin never substitutes another source. +`typeshed-store-path` only relocates the local store, while `stub-paths` remains the separate step-1 override ([STUBRES-TYPESHED](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED)). @@ -1028,12 +1028,9 @@ Project TOML example: [tool.basilisk] python-platform = "All" # Explicit cross-platform analysis stub-paths = ["stubs/"] # resolution step 1: prepend extra .pyi stub dirs -# Unpinned acquisition resolves current python/typeshed@main on startup: +# An unset pin IS the bundled commit; `basilisk typeshed download` updates it: # typeshed-commit = "" # optional explicit immutable source -# typeshed-url = "https://mirror.example/typeshed/{sha}.zip" -# typeshed-cache-path = ".cache/typeshed" -# typeshed-cache = true -# typeshed-verify = true +# typeshed-store-path = ".cache/typeshed" # optional: relocate the local store # typeshed-path = "typeshed-x" # resolution step 3: your sole custom stdlib tree include = ["src/", "tests/"] exclude = ["**/migrations/**"] @@ -1205,9 +1202,21 @@ Every error has at least one associated code action: ### Parallelism {#CHKARCH-PERF-PARALLEL} -- File-level parallelism using Rayon (work-stealing) -- Module dependency graph partitioned into independent subgraphs -- Cross-module dependencies resolved first in dependency-ordered pass +Analysis is single-threaded, by design and in fact. `check` / `analyze` / `fix` / +`adopt` all run on one dedicated large-stack thread (`run_with_analysis_stack`, +[LSPARCH-ARCH-STACK]) because the AST walk recurses deeply enough to overflow the +default main-thread stack. No Basilisk crate calls Rayon — `rayon` reaches the +lockfile only as a transitive dependency of `salsa` and `ruff_db`. + +Concurrency lives in the LSP server instead, on Tokio: request multiplexing, plus +`spawn_blocking` for the genuinely blocking work (typeshed download, debug-adapter +accept loop, process enumeration). What keeps an edit sub-10ms is Salsa's +incremental invalidation ([CHKARCH-INCREMENTAL]), not thread count. + +File-level parallelism stays a future option. It is not implemented, and no +benchmark number in this repository depends on it — so a change that adds it must +still clear the benchmark ratchet on its own merits +([CHKARCH-TESTING-BENCH-RATCHET]). ### Memory {#CHKARCH-PERF-MEMORY} diff --git a/docs/specs/CHECKER-CACHE-SPEC.md b/docs/specs/CHECKER-CACHE-SPEC.md index 4aea5bbd..915e98f0 100644 --- a/docs/specs/CHECKER-CACHE-SPEC.md +++ b/docs/specs/CHECKER-CACHE-SPEC.md @@ -35,10 +35,10 @@ Inputs that can affect the diagnostics for `basilisk check `: 6. **Standard-library typeshed identity** — the exact [`python/typeshed`](https://github.com/python/typeshed) commit SHA, custom-tree content identity, or bundled-ZIP identity - ([`STUBRES-TYPESHED-ACQUIRE`](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE), + ([`STUBRES-TYPESHED-PIN`](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-PIN), [`STUBRES-TYPESHED-BASELINE`](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-BASELINE)). - The selected identity moves **independently of the binary**: a fresh unpinned - acquisition or a `typeshed-commit` change swaps the stdlib `.pyi` bodies under a fixed + The selected identity moves **independently of the binary**: a + `typeshed-commit` change swaps the stdlib `.pyi` bodies under a fixed `CARGO_PKG_VERSION`, so keying only on the checker version would serve a stale entry across a typeshed update. The fingerprint MUST therefore key on the exact selected source identity as well diff --git a/docs/specs/CHECKER-MCP-SPEC.md b/docs/specs/CHECKER-MCP-SPEC.md index 7c41d995..56fce032 100644 --- a/docs/specs/CHECKER-MCP-SPEC.md +++ b/docs/specs/CHECKER-MCP-SPEC.md @@ -7,17 +7,18 @@ Basilisk ships one deliberately narrow Model Context Protocol surface: binary already carried by the binary archives, wheels, editor packages, and other release artifacts; it is not a second executable or workspace index. The server exposes source status only. It MUST NOT perform type-checking, -publish diagnostics, or invent a second typeshed acquisition state. +publish diagnostics, or invent a second typeshed resolution state. -The tool invokes the shared typeshed startup gate and serializes its canonical +The tool invokes the shared typeshed resolution and serializes its canonical status object ([STUBRES-TYPESHED-WARN](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN)). The underlying step-3 source is the custom canonical tree or typeshed stdlib defined by the pinned typing specification ([`python/typing@6ef9f7719ecfff09dad8724ef42b621fd994fb5e`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)); MCP changes only status transport, never that resolution order. -It is read-only with respect to user projects. Shared Latest acquisition may -contact the configured upstream and populate Basilisk's internal immutable -cache before status is available; MCP MUST NOT add another fetch or cache path. +It is read-only with respect to user projects and fully offline +([STUBRES-TYPESHED-OFFLINE](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-OFFLINE)): +resolution never contacts an upstream, and a pin missing from the store is a +status failure, never a fetch. MCP MUST NOT add a fetch or store-write path. ## Stdio lifecycle {#MCP-STDIO} @@ -39,8 +40,9 @@ There is no MCP-over-HTTP transport and no server-to-client request surface. `tools/list` declares exactly one tool, `basilisk_typeshed_status`, with an empty object input schema and read-only, non-destructive, idempotent annotations. Its -`openWorldHint` is true because shared Latest acquisition can access the -configured upstream. The result follows MCP +`openWorldHint` is `false`: resolution is offline by construction +([STUBRES-TYPESHED-OFFLINE](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-OFFLINE)), +so the tool never touches an open world. The result follows MCP [structured tool output](https://modelcontextprotocol.io/specification/2025-11-25/server/tools): the same JSON object appears as `structuredContent` and as serialized JSON in a text content block. @@ -49,25 +51,24 @@ The closed output schema contains: | Field | Contract | |---|---| -| `active_source` | Active custom, exact, latest, or bundled source label. | +| `active_source` | `custom`, `exact-commit`, or `bundled` — the active source IS the trust story; there are no separate transport/provenance fields. | | `commit_identity` | Full commit SHA, or `null` when the source has none. | | `tree_identity` | Full Git tree identity, or `null` when unavailable. | -| `transport` | Shared source transport, such as custom path or embedded ZIP. | -| `provenance` | `github-tls-attested`, `bundle-vetted`, `unverified`, or `user-managed`; attested does not imply a signed typeshed release. | -| `license_status` | Approved/review state; custom may say `not supplied`. | +| `license_status` | `approved`, `changed`, or `not supplied` (custom). | | `license_reference` | Safe immutable license reference, or `null` for custom. | -| `signed_release` | Always `false`: current typeshed sources are commits/archives, not signed releases. | | `warnings` | Canonically ordered `{code, message}` status warnings. | Field values and warning order MUST match the CLI and LSP status produced for -the same acquired source. Warning codes are stable display codes including -`UNPINNED`, `DOWNLOAD FAILED`, `LICENSE CHANGED`, `UNVERIFIED`, and -`USER-MANAGED SOURCE`. They are service status, never Python diagnostics. +the same resolved source. Warning codes are the stable display codes +`UNPINNED`, `USER-MANAGED SOURCE`, and `LICENSE CHANGED`, in that canonical +order ([STUBRES-TYPESHED-WARN](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN)). +They are service status, never Python diagnostics. Unknown methods/tools and invalid arguments return JSON-RPC errors. A shared -acquisition/status failure returns a tool result with `isError: true`; it MUST -NOT return partial provenance or substitute stale state. Responses contain no -archive bytes, source text, credentials, configured mirror URL, or user files. +resolution/status failure (including a pin missing from the store) returns a +tool result with `isError: true`; it MUST NOT return partial status or +substitute stale state. Responses contain no archive bytes, source text, +credentials, or user files. ## Acceptance {#MCP-ACCEPTANCE} diff --git a/docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md b/docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md index bfa19dd3..49c0011a 100644 --- a/docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md +++ b/docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md @@ -1,6 +1,6 @@ # Stub Resolution & Type Provenance — Specification {#STUBRES-OVERVIEW} -> **Crate**: `basilisk-stubs` (resolution, the downloaded `python/typeshed` archive + on-disk cache, and the bundled stdlib ZIP), `basilisk-config` (overrides) +> **Crate**: `basilisk-stubs` (resolution and the offline step-3 sources — the bundled stdlib ZIP, the content-addressed commit store, and offline pin verification; it links no HTTP client, see [§STUBRES-TYPESHED-OFFLINE](#STUBRES-TYPESHED-OFFLINE)), `basilisk-typeshed-fetch` (the segregated, user-invoked download that is the only code which opens a connection, see [§STUBRES-TYPESHED-DOWNLOAD](#STUBRES-TYPESHED-DOWNLOAD)), `basilisk-config` (overrides) > **Related**: [LSP-UV-INTEGRATION-SPEC.md §LSPUV-LOCK-REGISTRY](LSP-UV-INTEGRATION-SPEC.md#LSPUV-LOCK-REGISTRY) — `PackageRegistry` accelerates stub discovery --- @@ -125,7 +125,7 @@ reorder a resolution step. |---|---|---| | 1 — manual path head | User `.pyi` in `stub-paths`, generated `.basilisk/stubs/`, and `.pyi` or Python source in manual `extra-paths`; `.pyi` precedes `.py` at each location. These MAY shadow every later step. | `stub-paths`, `extra-paths` | | 2 — user code | Workspace `.pyi`/`.py` under roots / `include`, with `.pyi` first. | roots, `include` | -| 3 — stdlib typeshed | One selected source: a custom `typeshed-path`; otherwise the pinned or latest commit downloaded as an archive; otherwise the bundled stdlib ZIP — the complete typeshed `stdlib/` tree, third-party `stubs/` excluded ([§STUBRES-TYPESHED](#STUBRES-TYPESHED)). | `typeshed-path`, `typeshed-commit`, `typeshed-url`, `typeshed-cache-path`, `typeshed-cache`, `typeshed-verify` | +| 3 — stdlib typeshed | One selected source, always already on this machine: a custom `typeshed-path`, or the pinned commit — the complete typeshed `stdlib/` tree, third-party `stubs/` excluded ([§STUBRES-TYPESHED](#STUBRES-TYPESHED)). Checking never downloads ([§STUBRES-TYPESHED-OFFLINE](#STUBRES-TYPESHED-OFFLINE)). | `typeshed-path`, `typeshed-commit`, `typeshed-store-path` | | 4 — stub-only packages | Installed `foopkg-stubs` / typeshed `types-foopkg` distributions, discovered in site-packages. They supersede an inline-typed install of the same package. | (auto) | | 5 — `py.typed` packages | Installed packages shipping a `py.typed` marker (stubs in `.pyi` or inline in `.py`). | (auto) | | 6 — vendored third-party stubs | Basilisk vendors none for resolution. The typeshed distribution map drives only the "install stubs" quick fix ([§STUBRES-CODEACTIONS](#STUBRES-CODEACTIONS)). | — | @@ -153,11 +153,11 @@ Basilisk exposes that source as `typeshed-path`: typeshed-path = "typeshed-micropython" ``` -That directory is the sole step-3 source and disables archive download and the -bundled ZIP. A missing module continues at step 4; it never falls back -to another typeshed. Relative paths resolve from the workspace root and stdlib -stubs live at `/stdlib/.pyi`. `stub-paths` remains the -separate step-1 override; `typeshed-cache-path` only relocates cached downloads. +That directory is the sole step-3 source and excludes the pin and the bundled +ZIP. A missing module continues at step 4; it never falls back to another +typeshed. Relative paths resolve from the workspace root and stdlib stubs live at +`/stdlib/.pyi`. `stub-paths` remains the separate step-1 +override. ### Resolution flow {#STUBRES-RESOLUTION-FLOW} @@ -173,7 +173,7 @@ flowchart LR S1 -- miss --> S2{"2 · user code?"} S2 -- hit --> R2["Source"] S2 -- miss --> S3{"3 · selected stdlib source?"} - S3 -- hit --> R3["Typeshed resolved (custom / archive / bundled ZIP)"] + S3 -- hit --> R3["Typeshed resolved (custom folder / pinned local tree)"] S3 -- miss --> S4{"4 · stub package?"} S4 -- module hit --> R4["StubPackage"] S4 -- none --> S5{"5 · py.typed package?"} @@ -212,70 +212,108 @@ Pyright “ships with a bundled copy of typeshed type stubs” ([`microsoft/pyright@1bec65c`](https://github.com/microsoft/pyright/blob/1bec65c15fba26016281d44d977bf667b89b9d30/docs/configuration.md#L23)). Basilisk likewise never mixes a source's names, bodies, `VERSIONS`, or indexes. -| Mode | Active source | Failure rule | +There are exactly **two** sources, both already on this machine when checking +starts. There is no "track latest" source: freshness is an action a person takes +([§STUBRES-TYPESHED-DOWNLOAD](#STUBRES-TYPESHED-DOWNLOAD)), never something the +checker does on their behalf. + +| Source | Selected by | Active data | |---|---|---| -| Custom folder | `typeshed-path` verbatim | miss continues to step 4; no other step-3 source | -| Exact commit | the bundle when it is exactly that SHA (embedded bytes are content-addressed, so no acquisition runs); otherwise the selected archive (content-attested unless waived) | otherwise fail closed | -| Latest (default) | current `python/typeshed@main`, once per run/session | never reuse old unpinned data; warn and use bundled ZIP | - -Latest defaults to freshness and is one source choice — **Pinned commit** — from determinism. -Custom and bundled are also reported unpinned -([§STUBRES-TYPESHED-WARN](#STUBRES-TYPESHED-WARN)). - -#### Archive acquisition {#STUBRES-TYPESHED-ACQUIRE} - -Basilisk never clones. A pin naming the bundled commit is served from the -embedded ZIP without any network activity: the commit is content-addressed, so -the vetted embedded bytes are that source, and consulting the network first -would only add failure modes and spend rate-limited metadata calls. Every -other selection resolves official commit → root-tree metadata over -authenticated HTTPS, then downloads that SHA from GitHub codeload or a -`typeshed-url` `{sha}` archive mirror. A mirror cannot resolve Latest; if official -metadata is unavailable and no pin exists, Latest warns and uses the bundled ZIP. -URLs are redacted in logs. +| Pinned commit *(default)* | `typeshed-commit`; unset selects the bundled commit | the local tree carrying exactly that SHA — the embedded ZIP when the SHA is the bundled one, else that commit's store entry | +| Custom folder | `typeshed-path` | that tree verbatim, user-managed | -**Credential.** Requests carry `Authorization: Bearer` from `GITHUB_TOKEN` or -`GH_TOKEN` when either is set to a non-blank value — the names GitHub Actions and -the GitHub CLI already export, so no Basilisk-specific setup is needed. Anonymous -callers share GitHub's unauthenticated rate limit, which a shared CI egress IP -exhausts; an authenticated caller gets a much larger per-token budget -([GitHub rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)). -The credential is sent ONLY to `api.github.com` and `codeload.github.com`, matched -on the parsed authority. A `typeshed-url` mirror is third-party infrastructure and -is always contacted anonymously, so the token is never disclosed outside the trust -boundary it was issued for. The token value is never logged, never rendered in -debug output, and never included in an error; only its presence is recorded. +Both fail closed. Custom is reported unpinned +([§STUBRES-TYPESHED-WARN](#STUBRES-TYPESHED-WARN)); a *module* miss in a custom +tree still continues to step 4. + +#### The checker never downloads {#STUBRES-TYPESHED-OFFLINE} + +Analysis performs no network activity of any kind — structurally, not by +discipline: the crate the checker links against contains no HTTP client, so the +analysis path cannot reach the network even by mistake. Step 3 is a local +lookup: the source is present and verifies, or it is missing/corrupt and the +checker **fails hard** — it refuses to analyse, names the SHA it needed, and +never substitutes another source or degrades to an untyped stdlib. That failure +is service status (CLI stderr, LSP `showMessage` + Service Info, MCP), never a +Python diagnostic, so it can never create a conformance false positive. + +#### A pin is a verification {#STUBRES-TYPESHED-PIN} + +A pin does exactly one thing at check time: **proves the local tree is that +commit**, offline, by hashing bytes already on disk. + +1. hash the store entry's saved commit object — it MUST equal the pinned SHA; +2. read the tree SHA out of that verified commit object; +3. re-hash the stored tree into Git tree objects — the root MUST equal that SHA. + +Verification is **not waivable**: a pin you can switch off is a pin that does +nothing. The bundle cannot be tree-reconstructed (it is a `stdlib/` subset), so +it keeps its build-time proof — embedded ZIP SHA-256 plus license manifest +([§STUBRES-TYPESHED-BASELINE](#STUBRES-TYPESHED-BASELINE)) — and satisfies a pin +naming its commit. + +**Trust boundary.** This proves integrity since acquisition and binds commit→tree +cryptographically; it cannot prove offline that the SHA is an official typeshed +commit. That authenticity rests on GitHub/TLS at download time, and typeshed +publishes no signed release ([Git `commit-tree`](https://git-scm.com/docs/git-commit-tree), +[GitHub Git-commit API](https://docs.github.com/en/rest/git/commits)). Whoever can +rewrite the store can rewrite its commit object with it. + +#### The store {#STUBRES-TYPESHED-STORE} + +One immutable directory per commit under `typeshed-store-path`, read by the +checker and written only by the download action: -**Security boundary.** A pin is not an archive checksum or provenance proof: -Git defines a commit from a tree object ([Git `commit-tree`](https://git-scm.com/docs/git-commit-tree)), -and GitHub reports commit and tree SHAs separately ([GitHub Git-commit API](https://docs.github.com/en/rest/git/commits)). -Initial verification therefore authenticates GitHub commit→tree metadata and -reconstructs that tree from the consumed bytes. Cache rehashing proves only -local consistency; a hostile cache can replace ZIP and metadata, and typeshed -has no signed release. Custom or verification-disabled sources are `UNVERIFIED`, -never represented as official. +``` +/<40-hex commit sha>/ + commit-object # raw Git commit object; hashes to the directory name + manifest.json # the commit's full Git tree listing (path, blob SHA, mode) + stdlib/… LICENSE NOTICE… +``` + +No expiry, no reuse policy, no cache-off mode: an entry is a commit and bytes do +not go stale. Deleting a directory is the only eviction, and only a download +recreates it. -Decompression enforces entry and size caps before four activation gates run. -Accepted bytes are cached as an immutable ZIP and read through an archive VFS: +#### Downloading is a separate component {#STUBRES-TYPESHED-DOWNLOAD} + +Acquisition lives outside the checker and outside the configuration editor. It +is the only typeshed code that opens a connection, it runs only when a person +invokes it, and nothing on the analysis path can call it. + +| Invocation | Does | +|---|---| +| `basilisk typeshed download` / **Download latest** / `DownloadLatest` | resolve `main` → SHA, acquire it, **write that SHA as `typeshed-commit`** | +| `basilisk typeshed download --commit ` / `DownloadPinned` | acquire the SHA the config already names; writes no config | + +Download-pinned is how a teammate or a fresh CI machine materialises someone +else's pin; without it a shared pin is unusable on a machine that never +downloaded it. + +Basilisk still never clones: resolve official commit → root-tree metadata over +authenticated HTTPS, download that SHA from GitHub codeload, run the gates +below, reconstruct the commit object and assert it hashes to the requested SHA, +then dump the accepted tree into the store. There is no mirror setting — an +air-gapped or firewalled machine uses a custom folder. A download that fails at +any step writes **nothing** — no partial entry, no unverified entry, no config +change. URLs are redacted in logs. | Gate | Rule | |---|---| | Safety | reject absolute/`..` paths, escaping links, duplicate entries, and entry/decompressed-size limits | | Shape | require one coherent stdlib tree, `VERSIONS`, and license metadata | | License | path+SHA-256 manifest for relevant root/nested `LICENSE*`/`NOTICE*` must match a build-approved identity; drift blocks activation for review | -| Content | reconstruct Git trees and match the trusted root-tree SHA; only this gate may be waived | - -First acquisition records the accepted ZIP's SHA-256. Reuse hashes the cached -ZIP, detecting mutation without extraction; `.pyi` is read from that same ZIP. -Cache metadata records whether content verification ran; enabling it later MUST -rerun the content gate before the archive can be reported as verified. -The exact commit identity never expires. The 24-hour expiry bounds reuse of -unpinned downloaded ZIP bytes only. Bytes for an explicitly pinned exact commit -are reused regardless of age because the commit is content-addressed, and every -reuse re-hashes the ZIP against its recorded SHA-256. Expiry, explicit eviction, -or `typeshed-cache = false` reacquires the same selected SHA and reruns all gates. Disabling verification reports -`UNVERIFIED` and never disables safety, shape, or license review. A fresh -unpinned download is not hermetic. +| Content | reconstruct Git trees and match the commit's root-tree SHA | + +**Credential.** Requests carry `Authorization: Bearer` from `GITHUB_TOKEN` or +`GH_TOKEN` when either is set to a non-blank value — the names GitHub Actions and +the GitHub CLI already export, so no Basilisk-specific setup is needed. Anonymous +callers share GitHub's unauthenticated rate limit, which a shared CI egress IP +exhausts; an authenticated caller gets a much larger per-token budget +([GitHub rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)). +The credential is sent ONLY to `api.github.com` and `codeload.github.com`, matched +on the parsed authority. The token value is never logged, never rendered in +debug output, and never included in an error; only its presence is recorded. #### Bundled ZIP snapshot {#STUBRES-TYPESHED-BASELINE} @@ -313,40 +351,43 @@ Basilisk reports `active_source` plus an ordered `warnings[]`; warnings compose. | Condition | Persistent status | |---|---| -| Latest or bundled without explicit commit | `UNPINNED — choose the pinned-commit source to make this reproducible` | -| Custom folder | `UNPINNED — folder contents can change; version or content-address the folder externally` | -| Latest could not resolve, download, or validate | `DOWNLOAD FAILED — using bundled ; may be behind upstream` | +| no explicit `typeshed-commit` (the bundled commit is a build-time pin, not a user pin) | `UNPINNED — pin a commit to make this reproducible` | +| custom folder | `UNPINNED — folder contents can change; version or content-address the folder externally` | +| custom folder | `USER-MANAGED SOURCE — license and contents supplied by user` | | approved license/NOTICE identity changed | `LICENSE CHANGED — Basilisk update/review required` | -| content verification disabled | `UNVERIFIED — contents were not checked against the selected tree` | -| custom path | `USER-MANAGED SOURCE — license and contents supplied by user` | +| pinned commit absent from the store, or verification failed | `NO SOURCE — is not on this machine; run Download latest or basilisk typeshed download --commit ` — analysis does not run | CLI uses a stderr status banner without contaminating machine diagnostics; LSP uses `window/showMessage` plus persistent Service Info, never `publishDiagnostics`; MCP returns structured status. These warnings therefore cannot create conformance false positives. All surfaces show the full SHA when known; the UI also provides a safe View License action. MCP fields are -`active_source`, commit/tree identity, transport, provenance, `license_status`, -immutable license reference (or custom `not supplied`), and ordered `warnings[]`. +`active_source`, commit/tree identity, `license_status`, immutable license +reference (or custom `not supplied`), and ordered `warnings[]`. The active +source already names the trust story (custom = user-managed, bundled = +build-vetted, exact commit = attested at download and re-proven offline), so +there are no separate transport or provenance fields. #### Config keys {#STUBRES-TYPESHED-CONFIG} The only typing-spec-facing setting is the custom canonical path named by pinned step 3 ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)); -the rest govern download, caching, and verification, which the specification -leaves open. Every one is exposed as a control in the configuration UI +the rest govern the pin and where downloads land, which the specification leaves +open. Every one is exposed as a control in the configuration UI ([§LSPCFGED-TYPESHED](LSP-CONFIGURATION-EDITOR-SPEC.md#LSPCFGED-TYPESHED)). -| Config key / flag | Type | Default | Meaning | -|---|---|---|---| -| `typeshed-commit` | full SHA | unset | Exact commit; unset selects Latest. | -| `typeshed-url` | URL template | GitHub codeload | Codeload-compatible archive mirror containing `{sha}` and one common top-level directory; does not resolve Latest. | -| `typeshed-cache-path` | path | OS cache | Cached gate-accepted ZIPs. | -| `typeshed-cache` | bool | `true` | Reuse a re-hashed accepted downloaded ZIP for 24 hours when unpinned, or until evicted when pinned to an exact commit; false downloads, validates, and discards. | -| `typeshed-path` | `string` | _(unset)_ | Supply the canonical custom step-3 tree; disables download and the bundled ZIP. | -| `typeshed-verify` | bool | `true` | Content attestation; false reports `UNVERIFIED`. | -| `--no-typeshed-cache` | flag | off | One-run `typeshed-cache = false`. | -| `--no-typeshed-verification` | flag | off | One-run `typeshed-verify = false`; never bypasses other gates. | +| Config key | Type | Default | Meaning | Read by | +|---|---|---|---|---| +| `typeshed-commit` | full SHA | unset _(= the bundled commit)_ | The pinned commit, verified offline. | checker | +| `typeshed-path` | `string` | _(unset)_ | The canonical custom step-3 tree; excludes the pin and the bundle. | checker | +| `typeshed-store-path` | path | OS cache | Where downloads are dumped and pins are resolved. | both | + +That is the whole surface: three keys. There are no cache-reuse, expiry, +verification-waiver, or mirror settings, and no one-run flags: nothing is +cached, nothing expires, a pin always verifies +([§STUBRES-TYPESHED-PIN](#STUBRES-TYPESHED-PIN)), and downloads come only from +GitHub ([§STUBRES-TYPESHED-DOWNLOAD](#STUBRES-TYPESHED-DOWNLOAD)). #### Target Python version {#STUBRES-TYPESHED-VERSION} diff --git a/docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md b/docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md index 2fa222b1..933a2af6 100644 --- a/docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md +++ b/docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md @@ -1,4 +1,4 @@ -# [TYPEINF-SPEC] Basilisk type inference {#TYPEINF} +# Basilisk type inference {#TYPEINF} Basilisk combines conservative shared inference with focused typing-rule algorithms. The default configuration follows the typing specification; optional house rules can require or discourage annotations without changing PEP behavior (see [TYPEINF-REDUNDANT]). diff --git a/docs/specs/DOCS-README-SPEC.md b/docs/specs/DOCS-README-SPEC.md new file mode 100644 index 00000000..cf5addf0 --- /dev/null +++ b/docs/specs/DOCS-README-SPEC.md @@ -0,0 +1,83 @@ +# One README, many storefronts {#README} + +## Purpose {#README-PURPOSE} + +Basilisk's front page is published to three storefronts — the GitHub repository, +the VS Code Marketplace / Open VSX (both read the **same** file packaged into the +VSIX), and PyPI. They were three hand-maintained files, so they drifted: one +claimed a retired typeshed behaviour months after the others were corrected. + +There is now exactly **one** README per language, and the published files are +**identical except for a single line** that says which artifact you are looking +at. Everything else — the conformance claim, the benchmark table, the install +options, the feature list, the typeshed section, the acknowledgments — is one +body of text, generated to every storefront by `scripts/gen_readmes.py`. + +## Source {#README-SOURCE} + +| Source | Language | +|---|---| +| `docs/readme/README.src.md` | English | +| `docs/readme/README.zh.src.md` | 简体中文 | + +Nothing else is authored. Editing a generated README directly is a drift bug — +CI fails it ([README-DRIFT](#README-DRIFT)). + +## Targets {#README-TARGETS} + +| Target key | Output | Storefront | +|---|---|---| +| `github` | `README.md`, `README.zh.md` | The GitHub repository | +| `vscode` | `vscode-extension/README.md`, `vscode-extension/README.zh.md` | VS Code Marketplace **and** Open VSX — one VSIX, one file | +| `pypi` | `README-pypi.md` | The `basilisk-python` wheel | + +Open VSX is not a fourth README: `publish-vsix-ovsx` pushes the very VSIX the +Marketplace job pushes, so both registries render `vscode-extension/README.md`. + +## The one-line difference {#README-IDENTITY} + +Exactly one paragraph varies between targets, and it exists solely to tell the +reader which artifact this listing is. It is a single `` line per +target in the source. **No other content may be made target-specific** — a +second variant block is a review failure, not a feature: if a fact is worth +saying on one storefront it is worth saying on all of them. + +Two values are substituted rather than duplicated, because they are the same +statement expressed differently per target: `{{altLangHref}}` (the +language-switch link, which must be absolute anywhere but GitHub) and, in the +Chinese source, its mirror. They are not content. + +## Rendering {#README-RENDER} + +The generator applies three transforms, in order: + +1. **Variant blocks.** `` … `` keeps the enclosed + lines only for the listed targets; the list is comma-separated + (``). Markers are HTML comments, so the source renders + correctly on its own. +2. **Tokens.** `{{altLangHref}}` is substituted per target. +3. **Link absolutisation.** Every repo-relative link and image target is + rewritten for the non-`github` targets: images to + `raw.githubusercontent.com/.../main/`, everything else to + `github.com/.../blob/main/`. A relative path that resolves to no file + in the repository is a generation error, not a broken published link. + +## Stamped values {#README-STAMPED} + +The conformance and benchmark figures are not typed by hand anywhere. They are +`value` markers stamped into the **source** by +`scripts/gen_conformance_reference.py` from `conformance_report.json` and the +committed benchmark CSVs ([CHKARCH-CONFORMANCE](CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CONFORMANCE)). +Generation runs after stamping, so every storefront quotes the same +self-measured number. + +## Drift guard {#README-DRIFT} + +`python3 scripts/gen_readmes.py --check` re-renders every target and fails if +the committed file differs. It runs in the CI website job beside the conformance +stamp check, and `make lint` runs it locally. A README edited directly, or a +source edit without regeneration, fails the build. + +The same check asserts the structural rule in +[README-IDENTITY](#README-IDENTITY): every rendered target must differ from +`github` by the identity paragraph alone. diff --git a/docs/specs/LSP-ANALYSIS-MODES-SPEC.md b/docs/specs/LSP-ANALYSIS-MODES-SPEC.md index 386b8866..a860d44d 100644 --- a/docs/specs/LSP-ANALYSIS-MODES-SPEC.md +++ b/docs/specs/LSP-ANALYSIS-MODES-SPEC.md @@ -271,12 +271,13 @@ No workspace scan; the server waits for `didOpen` notifications. ### wholeModule Startup {#ANALYSIS-STARTUP-WHOLE} -On `initialized`, Basilisk acquires the selected step-3 stdlib source and gates -the first check on it ([STUBRES-TYPESHED-ACQUIRE](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE)). +During `initialize`, Basilisk resolves the selected step-3 stdlib source from +local sources only and gates the first check on it +([STUBRES-TYPESHED-OFFLINE](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-OFFLINE)). This implements "Typeshed stubs for the standard library" in the pinned typing order ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). -The source is the custom path, an exact-SHA archive for an explicit commit or -current `main`, or the bundled stdlib ZIP; no previous unpinned archive is eligible. The scan +The source is the custom path or the pinned commit verified offline from the +store/bundle; a missing pin is a terminal `NO SOURCE`, never a substitute. The scan then builds import paths, primes Salsa, analyzes each workspace file once, and publishes diagnostics. Open buffers are re-analyzed from editor text. Progress and the selected source appear in Service Info @@ -301,7 +302,7 @@ The `resolve` step of any incremental re-check (`didOpen`, `didChange`, disk rel The cached `ImportSearchPaths` include the selected typeshed path and exact source identity. A `typeshed-path` or `typeshed-commit` change invalidates them and rebuilds step 3 before rechecking -([STUBRES-TYPESHED-ACQUIRE](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-ACQUIRE)). +([STUBRES-TYPESHED-PIN](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-PIN)). This derived cache changes performance only; it preserves the pinned resolution order ([`python/typing@6ef9f77`](https://github.com/python/typing/blob/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/docs/spec/distributing.rst)). It is reused across keystrokes, never across a source or configuration change. @@ -425,19 +426,34 @@ panel-payload gating, header/row neutrality, and zero-diagnostics refresh). ## LSP Capabilities {#ANALYSIS-CAPS} -When `analysisMode` is `wholeModule` or `crossModule`, the server advertises: +Workspace capabilities are **mode-independent**. `build_capabilities` +(`crates/basilisk-lsp/src/server/init.rs`) is parameterised only by whether the +formatter engine is enabled ([LSPFMT-CAPABILITIES](LSP-FORMATTING-SPEC.md#LSPFMT-CAPABILITIES)), +so the `initialize` response advertises the same workspace block in every +`analysisMode`, `openFilesOnly` included: ```json "workspace": { + "workspaceFolders": { "supported": true, "changeNotifications": true }, "fileOperations": { - "didCreate": { "filters": [{ "pattern": { "glob": "**/*.py" } }] }, - "didDelete": { "filters": [{ "pattern": { "glob": "**/*.py" } }] }, - "didRename": { "filters": [{ "pattern": { "glob": "**/*.py" } }] } + "willRename": { + "filters": [ + { "scheme": "file", "pattern": { "glob": "**/*.py", "matches": "file" } } + ] + } } } ``` -When `analysisMode` is `openFilesOnly`, these capabilities are omitted. +`workspace/willRenameFiles` is the only file-operation method the server serves +(`crates/basilisk-lsp/src/server/handlers/file_operations.rs`): renaming a module +returns a `WorkspaceEdit` rewriting the imports that pointed at it +([REFACTOR-RENAMEMOD](LSP-REFACTORING-SPEC.md#REFACTOR-RENAMEMOD)). The +`didCreate` / `didDelete` / `didRename` notifications are neither advertised nor +handled — creations and deletions reach the index through +`workspace/didChangeWatchedFiles`, which is where the mode gate actually lives: +that handler returns early in `openFilesOnly` after refreshing configuration +([ANALYSIS-INCR-WATCH]). --- diff --git a/docs/specs/LSP-ARCHITECTURE-SPEC.md b/docs/specs/LSP-ARCHITECTURE-SPEC.md index 97226656..bf1a9edb 100644 --- a/docs/specs/LSP-ARCHITECTURE-SPEC.md +++ b/docs/specs/LSP-ARCHITECTURE-SPEC.md @@ -40,9 +40,8 @@ their installation flows in their editor specs. The server configuration model is `crates/basilisk-lsp/src/config.rs`. Editor manifests and settings must map to that model rather than maintaining a second semantic configuration. The stable shared surface includes the executable and Python paths, analysis mode, stub -paths, the typeshed source, mirror, cache, and verification settings -(`typeshed-path`, `typeshed-commit`, `typeshed-url`, `typeshed-cache-path`, -`typeshed-cache`, `typeshed-verify` — +paths, the three typeshed source settings +(`typeshed-path`, `typeshed-commit`, `typeshed-store-path` — [STUBRES-TYPESHED-CONFIG](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-CONFIG)), formatter selection, inlay-hint switches, debugger settings, and the uv, test, profiling, and memory namespaces. The custom path implements the pinned typing specification's @@ -288,7 +287,36 @@ navigation and display features. ### Hover {#LSPARCH-FEATURES-HOVER} Hover presents the resolved symbol signature and relevant Basilisk diagnostics; unknown -inferred pieces are represented explicitly rather than fabricated. A non-PEP diagnostic's +inferred pieces are represented explicitly rather than fabricated. + +**A hover answers for the symbol the cursor is actually on.** How the identifier is reached +decides how it is resolved, and the two forms must never be mixed: + +- A **free name** (`getLogger`, `MyClass`) resolves against the module's own bindings — + local definitions first, then the names its imports bind. +- A **member access** (`logger.error`, `os.getcwd`, `" ".join`) resolves *through its + receiver*: the module the receiver binds, a class in the local hierarchy, an external + (stub or `py.typed`) class, then a built-in type. The receiver is typed from its own + declaration only — its annotation, the literal assigned to it, or the return type of the + call that produced it. + +`ResolvedModule::imported_symbols` is keyed by bare name and a plain `import os` publishes +every member of `os` into it, so consulting it for a member access would answer +`logger.error` with whichever imported module last exported the word `error`. It may +therefore only be consulted for a name the module genuinely **binds** — verified by the +resolved file the binding import points at, not by the name alone. The same rule governs +the provenance annotation: a symbol is attributed to an import only when an import bound +it. **A receiver nothing can type yields no hover** — silence is correct where a confident +wrong answer is not. + +Each rendered symbol states, in order, what kind of thing it is (`(function)`, `(method)`, +`(class)`, `(variable)` — the same vocabulary local and imported symbols share), its exact +declared shape (every overload of an overload set, and a class's declared bases), its own +documentation when the defining module carries any (`.pyi` stubs, Typeshed included, carry +none), and where the declaration was read from: its module, its provenance, and its source +path. Absent pieces are omitted, never invented. + +A non-PEP diagnostic's hover section additionally carries a **Configure Severity** command link (`command:basilisk.openConfigurationEditor` with a `{ "rule": }` argument) that deep-links into the configuration editor focused on the rule diff --git a/docs/specs/LSP-CONFIGURATION-EDITOR-SPEC.md b/docs/specs/LSP-CONFIGURATION-EDITOR-SPEC.md index 0202c205..fbc040ef 100644 --- a/docs/specs/LSP-CONFIGURATION-EDITOR-SPEC.md +++ b/docs/specs/LSP-CONFIGURATION-EDITOR-SPEC.md @@ -26,7 +26,7 @@ All configuration-editor requests require an explicit active-workspace `rootUri` - `basilisk/previewConfigurationChange` validates the base revision, builds a validated in-memory patch from the requested mutations, and reruns checking against that hypothetical config, returning the resolved per-rule effective-severity changes and before/after impact. - `basilisk/applyConfigurationChange` consumes a cached preview identified by root and preview ID; the server rejects it if the preview's pinned base revision no longer matches the current document. It asks the client to apply one configuration edit, reloads and rechecks the root, republishes diagnostics, emits `basilisk/configurationChanged`, and returns a fresh snapshot. - `basilisk/ruleOccurrences` returns URI/range/code-ordered pages selected by the all/codes/tags selectors. The opaque cursor resumes the stable result and the server accepts limits from 1 to 1000. -- `basilisk/typeshedAction` accepts only `PinCurrent`, `AcquireFresh`, or `ViewLicense`; it returns an ordinary config preview, refreshed snapshot, or safe read-only license document respectively. +- `basilisk/typeshedAction` accepts only `DownloadLatest`, `DownloadPinned`, or `ViewLicense`; it returns an ordinary config preview (the new pin), a refreshed snapshot, or a safe read-only license document respectively. A mutation is `SetRule`, `RemoveRule`, `SetTag`, `RemoveTag`, `SetTypeshedSetting`, or `RemoveTypeshedSetting`; the latter two accept only the typed keys in [§LSPCFGED-TYPESHED](#LSPCFGED-TYPESHED). Requesting `disabled` for a `pep`-tagged rule is an error: PEP rules are graded, never disabled ([CHKARCH-CONFIG-MODEL](CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CONFIG-MODEL)). @@ -44,7 +44,7 @@ The protocol is deliberately small: - a snapshot is the root, config URI/revision, the active source (URI/exists/read-only), rule/tag states, the discovered per-folder path overrides, a server-computed debt summary (the errors/warnings/infos partition plus adopted/disabled rule counts), the real configuration problems, and server-described Typeshed settings/status; - `EditorMutation` adds allowlisted `SetTypeshedSetting` / `RemoveTypeshedSetting` to the four rule/tag variants; -- `TypeshedAction` is the closed `PinCurrent` / `AcquireFresh` / `ViewLicense` union; +- `TypeshedAction` is the closed `DownloadLatest` / `DownloadPinned` / `ViewLicense` union; - a preview is the resolved per-rule effective-severity changes (`Disabled` = does not run, never present on a `pep` rule) plus a complete errors/warnings/infos before/after partition; and - occurrences are paged locations with the severity that produced them. @@ -95,37 +95,26 @@ Freshness is the default; determinism is one control away. Every key in [STUBRES-TYPESHED-CONFIG](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-CONFIG) is editable here. -The wire model makes an impossible source unrepresentable: the snapshot carries -ONE active source that holds the value defining it — `Latest`, -`ExactCommit { commit }`, or `CustomFolder { path }` — so "latest with a pin" or -"a pin plus a custom folder" cannot be described at all. Alongside it the server -sends the download policy that source HAS (absent for a custom folder, which -downloads nothing), the commit `PinCurrent` would write when pinning is possible, -and whether a license document exists to open. There are no per-control widget, -label, or enabled descriptors: copy is client presentation, and availability is -the data itself. +There are **two** sources and no third. The snapshot carries ONE active source +holding the value that defines it — `ExactCommit { commit }` or +`CustomFolder { path }` — so "a pin plus a custom folder" cannot be described at +all. Alongside it the server sends the store folder (absent for a custom +folder), and whether a license document exists to open. There are no per-control +widget, label, or enabled descriptors: copy is client presentation, availability +is the data itself. | Source | Chosen by | Writes | Step-3 effect | |---|---|---|---| -| **Latest** *(default)* | selecting it | clears `typeshed-commit` + `typeshed-path` | resolves & downloads the newest `main` SHA | -| **Pinned commit** | selecting it (`PinCurrent`), or editing the SHA | full `typeshed-commit`, clears `typeshed-path` | selected SHA; verified unless disabled; failure closes unless bundle matches | -| **Custom folder** | selecting it (folder-picker) | `typeshed-path`, clears `typeshed-commit` | canonical user-managed tree; no download/bundle | +| **Pinned commit** *(default)* | editing the SHA, or **Download latest** | full `typeshed-commit`, clears `typeshed-path` | that SHA, verified offline; fails closed if it is not on this machine | +| **Custom folder** | selecting it (folder-picker) | `typeshed-path`, clears `typeshed-commit` | canonical user-managed tree | Selecting a source is one atomic transition, and only the ACTIVE source's own -field is rendered. Pinning is the source choice itself — never a second button — -and is offered only when there is an active commit to pin; otherwise the choice -states why (an in-flight acquisition, or a custom folder with no upstream commit). -A SHA that is not 40 hexadecimal characters is refused in the field and never -reaches the configuration. - -Download policy (Latest and Pinned commit only): +field is rendered. A SHA that is not 40 hexadecimal characters is refused in the +field and never reaches the configuration. | Control | Key | Widget | |---|---|---| -| Reuse downloads | `typeshed-cache` | toggle + one-run fresh-download action | -| Verify content | `typeshed-verify` | toggle; disabling requires confirmation | -| Alternate archive URL | `typeshed-url` | text (`{sha}` template), under Advanced | -| Cache folder | `typeshed-cache-path` | folder-picker, under Advanced | +| Store folder | `typeshed-store-path` | folder-picker, under Advanced | | License | active source | **View license**, or `not supplied` for custom | A Typeshed edit has no rule-severity impact to weigh, so it is written as soon as @@ -134,19 +123,12 @@ and every control re-renders from the snapshot that results. The impact dialog remains for rule and tag entries; dismissing it discards the change and returns every control to the configuration that still holds. -The URL downloads only a known SHA; Latest still needs official metadata. -Downloaded cached ZIP bytes are re-hashed every time; exact pins are reused -regardless of age until explicitly evicted, while Latest downloads expire after 24 hours. -Cache off downloads, validates, and discards—it is not labelled hermetic. -Verification off leaves safety, shape, and license gates active and displays `UNVERIFIED`. -Only a pinned commit suppresses `UNPINNED`; Latest/bundled offer the pinned source, -while Custom says its folder can change and should be versioned or content-addressed externally. -Custom shows user-managed terms, never the typeshed composite license +Only a pinned commit suppresses `UNPINNED`; Custom says its folder can change and +should be versioned or content-addressed externally, and shows user-managed terms +rather than the typeshed composite license ([STUBRES-TYPESHED-WARN](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN)). -`PinCurrent` returns a preview that writes the active SHA; `AcquireFresh` performs -one cache-bypassing acquisition, or re-snapshots the selected custom tree; `ViewLicense` returns the active immutable license document, or `not supplied` -for custom. Clients execute none locally. +for custom. Clients execute nothing locally. The two directory keys render with a native folder-picker rather than free text; they are the only path-typed settings the editor exposes, distinct from the @@ -156,8 +138,24 @@ Folder selection feeds the ordinary validated transaction in [§CONFIGEDITOR-SOURCES](#CONFIGEDITOR-SOURCES); cancellation writes nothing and restores the controls to the active source. -While a candidate is being acquired every source, control, and action is inert: -acquisition is one atomic transition and nothing may race it. +### Download {#LSPCFGED-TYPESHED-DOWNLOAD} + +Downloading is **not configuration**: it is a button, backed by a component that +lives outside the editor +([STUBRES-TYPESHED-DOWNLOAD](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-DOWNLOAD)). +The editor never downloads to satisfy an edit, and no configuration change ever +triggers one. + +| Button | Offered when | Does | +|---|---|---| +| **Download latest** | always, except while a download is running | resolves `main`, acquires it, writes that SHA as `typeshed-commit` | +| **Download pinned** | the pinned commit is not on this machine | acquires exactly that SHA; writes no configuration | + +A running download shows progress **on the button that started it**. Nothing +else is blocked: there is no full-panel overlay, no modal, and no lock screen — +every other control stays live, because reading and writing configuration never +waited on the network in the first place. Changing the pin re-resolves locally +and takes effect immediately. ### Service Info tree {#LSPCFGED-TYPESHED-SERVICE-INFO} @@ -166,17 +164,15 @@ from [STUBRES-TYPESHED-WARN](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WA | State | Row | |---|---| -| acquiring | spinner until the source is ready | -| exact pin unavailable or rejected | persistent `BLOCKED`; no substitute source | -| no explicit commit | persistent `UNPINNED`, including Custom and bundled | -| automatic fallback | persistent high-severity bundled-SHA warning | +| pin absent from this machine, or verification failed | persistent `NO SOURCE`; analysis does not run; no substitute source | +| no explicit commit (the bundled commit is serving) | persistent `UNPINNED` | | license drift | persistent `LICENSE CHANGED`; activation blocked | -| verification waived | persistent `UNVERIFIED` | -| custom | persistent `USER-MANAGED SOURCE` | +| custom | persistent `UNPINNED` + `USER-MANAGED SOURCE` | +| download running | spinner, on that action only | Rows may coexist, never poll, and never mutate; fixes remain in the editable -section. A warning row's message names its fix (e.g. `UNPINNED`'s **Pin -current**), so the row carries exactly one navigation-only command that opens +section. A warning row's message names its fix (e.g. `NO SOURCE`'s **Download +pinned**), so the row carries exactly one navigation-only command that opens the configuration editor — attached only while the server is running and advertises the editor capability, because the open command is gated on that same pair and a shown-but-dead command is forbidden. Other rows carry no diff --git a/docs/specs/LSP-PROFILING-SPEC.md b/docs/specs/LSP-PROFILING-SPEC.md index bde841d4..87974eb5 100644 --- a/docs/specs/LSP-PROFILING-SPEC.md +++ b/docs/specs/LSP-PROFILING-SPEC.md @@ -157,6 +157,15 @@ Anchored basename/path-segment checks remove runpy/debugpy/pydevd and Basilisk s at the common ingest point. Machinery-only threads are dropped, while user paths that merely contain those strings remain. +The stdlib thread-bootstrap spine (`threading.py`'s `_bootstrap`, `_bootstrap_inner`, `run`) +is stripped on the same terms, because every non-main thread starts there. Stripping it roots +each worker thread's flame chart at the user's own entry function, and it is what makes the +"machinery-only threads are dropped" rule actually reach the debugger's housekeeping threads — +started by the stdlib, they would otherwise survive on that spine once their debugpy frames +were removed. Only the bootstrap trio qualifies: a genuine `threading` wait (`join`, +`acquire`) is behaviour the user asked about, and a user's own `run` override lives in the +user's file, so the basename check never reaches it. + ### Thresholds {#PROFILE-AGGREGATION-THRESHOLD} Defaults are 1% for hot lines, 2% for hot functions, and 20 diagnostics per file. diff --git a/docs/specs/LSP-TEST-INTEGRATION-SPEC.md b/docs/specs/LSP-TEST-INTEGRATION-SPEC.md index 6b804358..b9b3ddce 100644 --- a/docs/specs/LSP-TEST-INTEGRATION-SPEC.md +++ b/docs/specs/LSP-TEST-INTEGRATION-SPEC.md @@ -14,7 +14,11 @@ Same subprocess-delegation pattern as debugging (debugpy) — note formatting is 2. **Execution** — delegate to a `pytest` subprocess (or `unittest` runner), using `uv run` when a uv project is detected 3. **Result streaming** — stream pass/fail/skip/error results to the editor's test UI -Rust implementation: `crates/basilisk-lsp/src/test_discovery.rs`. +Rust implementation: `crates/basilisk-lsp/src/test_discovery/` — `mod.rs` (the +`TestItem` data model below), `discovery.rs` (AST scan), `runner.rs` (pytest +subprocess + output parsing). The LSP request/command handlers that drive it, +including the pytest-availability diagnostic, live in +`crates/basilisk-lsp/src/server/test_handlers.rs`. --- @@ -159,13 +163,13 @@ pub fn build_test_command( The `PackageRegistry` (built from `uv.lock`) enables test dependency diagnostics: -| Condition | Diagnostic | Severity | Code | Code Action | +| Condition | Surface + message | Severity | Code | Code Action | |-----------|-----------|----------|------|-------------| -| pytest not in `uv.lock` | `Test runner \"pytest\" is not installed. Run \"uv add --dev pytest\" to install.` | Warning | `BSK-0015` | `basilisk.uv.addDev` with `pytest` | -| `pytest-cov` not in `uv.lock` (coverage requested) | `Coverage plugin \"pytest-cov\" is not installed.` | Info | — | `basilisk.uv.addDev` with `pytest-cov` | -| Test imports unresolved package | Standard imports_unresolved with uv context (see [LSP-UV-INTEGRATION-SPEC.md §5](LSP-UV-INTEGRATION-SPEC.md)) | Error | — | `basilisk.uv.add` | +| pytest not in `uv.lock` | Diagnostic on the test file: `Test runner \"pytest\" is not installed. Run \"uv add --dev pytest\" to install.` | Warning | `BSK-0015` | `basilisk.uv.addDev` with `pytest` | +| `pytest-cov` not in `uv.lock` (coverage requested) | `window/logMessage`: `Basilisk: pytest-cov not found in uv.lock — install it for coverage support` | Info | — | — (no diagnostic, so no quick fix) | +| Test imports unresolved package | Standard imports_unresolved diagnostic with uv context (see [LSP-UV-INTEGRATION-SPEC.md §5](LSP-UV-INTEGRATION-SPEC.md)) | Error | — | `basilisk.uv.add` | -These diagnostics are only emitted in uv projects and respect the existing `basilisk.uv.enabled` setting. +These signals are only emitted in uv projects and respect the existing `basilisk.uv.enabled` setting. ### Coverage with `uv run` {#LSPTEST-UV-INTEGRATION-COVERAGE} @@ -247,4 +251,4 @@ logged for diagnostics but is not authoritative. ### Interaction with uv Commands {#LSPTEST-LSP-PROTOCOL-UV-INTERACTION} -Test code actions invoking uv (e.g. "Add pytest" on a missing test runner) reuse `basilisk.uv.addDev`. Its post-command hook ([LSP-UV-INTEGRATION-SPEC.md §7.2](LSP-UV-INTEGRATION-SPEC.md)) re-parses the lock and rebuilds the registry, updating test runner availability. +Test code actions invoking uv reuse `basilisk.uv.addDev`. The one shipped today is the quick fix attached to the `BSK-0015` "pytest not installed" diagnostic above — titled **"Install pytest (uv add --dev pytest)"**, `CodeActionKind::QUICKFIX`, `isPreferred`, running `basilisk.uv.addDev` with the single argument `"pytest"` (`crates/basilisk-lsp/src/code_actions/mod.rs`). The command's post-command hook ([LSP-UV-INTEGRATION-SPEC.md §7.2](LSP-UV-INTEGRATION-SPEC.md)) re-parses the lock and rebuilds the registry, updating test runner availability. diff --git a/docs/specs/NEOVIM-SPEC.md b/docs/specs/NEOVIM-SPEC.md index 3eebde28..8ddad2a4 100644 --- a/docs/specs/NEOVIM-SPEC.md +++ b/docs/specs/NEOVIM-SPEC.md @@ -43,6 +43,12 @@ basilisk.nvim/ │ ├── profiling.lua # Profiling commands (see LSP-ARCHITECTURE-SPEC.md for LSP commands) │ ├── memory.lua # Memory commands (see LSP-ARCHITECTURE-SPEC.md for LSP commands) │ ├── testing.lua # Test discovery, tree UI, run/debug +│ ├── codelens.lua # Version-compatible code lens activation +│ ├── modules.lua # Module Explorer panel (basilisk.workspaceModules) +│ ├── info.lua # Info float: status, versions, integrations +│ ├── type_health.lua # Type Health panel (per-module coverage) +│ ├── tab_tracking.lua # Buffer/tab tracking for openFilesOnly mode +│ ├── ui.lua # Shared floating-window + client-lookup helpers │ ├── statusline.lua # Status line component (lualine compat) │ ├── health.lua # :checkhealth basilisk │ └── log.lua # Logger (vim.notify + optional file) @@ -51,14 +57,17 @@ basilisk.nvim/ ├── after/ │ └── lsp/ │ └── basilisk.lua # Neovim 0.11+ native LSP config (fallback for non-setup users) +├── lspconfig/ +│ └── basilisk.lua # Standalone nvim-lspconfig server definition, for +│ # upstream PR [NVIM-DISTRIBUTION-SECONDARY-LSPCONFIG-PR] ├── doc/ │ └── basilisk.txt # Vim help file └── tests/ ├── minimal_init.lua # Isolated test init - └── basilisk/ - ├── binary_spec.lua - ├── config_spec.lua - └── lsp_spec.lua + ├── basilisk/ # Unit specs for lua/basilisk/ modules + ├── lsp/ # e2e specs driving the real binary over LSP + ├── dap/ # e2e debug specs (nvim-dap ↔ debugpy) + └── ui/ # Screenshot/statusline/testing UI specs ``` --- @@ -397,7 +406,9 @@ require('basilisk').setup({}) -- zero-config, works out of the box ### Secondary: nvim-lspconfig PR {#NVIM-DISTRIBUTION-SECONDARY-LSPCONFIG-PR} -Submit `lsp/basilisk.lua` to nvim-lspconfig for users wanting basic LSP only: +`lspconfig/basilisk.lua` is the server definition to submit to nvim-lspconfig for +users wanting basic LSP only. It is standalone — plugin users never load it, +because the plugin uses native `vim.lsp.config`/`vim.lsp.enable`: ```lua -- Minimal nvim-lspconfig setup (no DAP, no test explorer, no profiling) @@ -406,12 +417,16 @@ require('lspconfig').basilisk.setup({}) ### CI {#NVIM-DISTRIBUTION-CI} -GitHub Actions: the `test-nvim` job in `ci.yml` runs the plenary.nvim suite on a pinned Neovim 0.11.x (the minimum supported version). +GitHub Actions: the `test-nvim` job in `ci.yml` runs `scripts/test-nvim.sh` — the `tests/basilisk` unit specs, the `tests/dap` debug-adapter specs, the `tests/lsp` real-binary e2e suite, and the `tests/ui` screenshot specs, in that order — over a two-leg matrix — a pinned `v0.11.6` (the supported floor) and `nightly` (forward-compat tip) — with `fail-fast: false`, so one leg's failure never masks the other's result. + +Because the matrix legs report as `Neovim Extension (0.11)` / `(nightly)` rather than the pre-matrix job name, a fan-in job `nvim-gate` (display name `Neovim Extension`) is the required branch-protection context: it runs `if: always()` and passes only when `needs.test-nvim.result` is `success` or `skipped`, so a failed or cancelled leg fails the gate. This mirrors the `mutation-test-shard` → `mutation-test` pattern. + +Only the matrix job is change-scoped (`needs.changes.outputs.core == 'true' || needs.changes.outputs.nvim == 'true'`): a core checker/LSP change re-runs the suite, because the e2e harness drives the real binary over LSP/DAP. `nvim-gate` deliberately carries no scope condition at all — it does not even `needs: changes` — because a required context that skips never reports, and `skipped` is one of the two results it accepts. ### Release & Versioning {#NVIM-DISTRIBUTION-RELEASE} -`basilisk.nvim/` is canonical in the `Nimblesite/Basilisk` monorepo, but plugin managers (and `vim.pack`) install only a repo whose root *is* the plugin — none install from a subdirectory. On each `vX.Y.Z` tag, the `publish-nvim` job in `release.yml` mirrors the `basilisk.nvim/` tree to the standalone repo **`Nimblesite/basilisk.nvim`** (the repo users install), using the same convention as `publish-homebrew` / `publish-scoop`: clone the sibling repo with the shared `BREW_SCOOP_PAT` via `x-access-token`, replace its content with the plugin tree, commit as `github-actions[bot]` (`basilisk ${VERSION}`), and push. +`basilisk.nvim/` is canonical in the `Nimblesite/Basilisk` monorepo, but plugin managers (and `vim.pack`) install only a repo whose root *is* the plugin — none install from a subdirectory. On each `vX.Y.Z` tag, the `publish-nvim` job in `release.yml` mirrors the `basilisk.nvim/` tree to the standalone repo **`Nimblesite/basilisk.nvim`** (the repo users install), using the same convention as `publish-homebrew` / `publish-scoop`: clone the sibling repo with the shared `BREW_SCOOP_PAT` via `x-access-token`, replace its content with the plugin tree (preserving the mirror's own `.git`), commit as `github-actions[bot]` (`basilisk ${VERSION}`, the tag with its leading `v` stripped), and push. The job `needs: release` and both the push and the tag are idempotent — an unchanged tree or an existing tag is a no-op, not a failure. The mirror is tagged with the identical `vX.Y.Z`, so the plugin version matches the binary that `binary.lua` auto-downloads from `Nimblesite/Basilisk` releases and that version-pinned installs (`vim.pack` / lazy.nvim) resolve. Versioning is **tag-only** — no embedded version string; `:BasiliskInfo` and `:checkhealth basilisk` report the binary version, which equals the tag. -The core plugin is shipped. Registry submission and mirror publication remain external release follow-ups in the [roadmap](../plans/ROADMAP-NEXT-STEPS-PLAN.md#NEXTSTEPS-DISTRIBUTION). +The core plugin is shipped and the mirror push is automated by `publish-nvim`. What remains are the human-gated upstream registry submissions (starting with the `lspconfig/basilisk.lua` PR to nvim-lspconfig) and one end-to-end tagged-release validation with the real credentials; both tracked in the [roadmap](../plans/ROADMAP-NEXT-STEPS-PLAN.md#NEXTSTEPS-DISTRIBUTION). diff --git a/docs/specs/REPO-STANDARDS-SPEC.md b/docs/specs/REPO-STANDARDS-SPEC.md new file mode 100644 index 00000000..d54e4201 --- /dev/null +++ b/docs/specs/REPO-STANDARDS-SPEC.md @@ -0,0 +1,243 @@ +# Repository standards {#REPO-STANDARDS} + +Repo-wide gates that are configured at the repository root or under `.github/` +rather than inside a crate. Each section below is the target of a `[ID]` comment +in the file it governs, so the citation resolves in one hop. + +| Section | Governs | +|---|---| +| [CI-DESLOP](#CI-DESLOP) | `.deslop.toml`, `Makefile` `_lint_deslop`, `scripts/install-deslop.sh`, the CI `Install deslop` step | +| [COVERAGE-THRESHOLDS-JSON](#COVERAGE-THRESHOLDS-JSON) | `coverage-thresholds.json`, `scripts/common.sh` | +| [GITIGNORE-RULES](#GITIGNORE-RULES) | `.gitignore` IDE/editor block | +| [GITHUB-DEPENDABOT](#GITHUB-DEPENDABOT) | `.github/dependabot.yml`, `.github/workflows/dependabot-automerge.yml`, the Dependabot skips in `ci.yml`/`codeql.yml` | +| [GITHUB-CODE-SCANNING](#GITHUB-CODE-SCANNING) | `.github/workflows/codeql.yml`, `.github/codeql/codeql-config.yml` | +| [GITHUB-DEP-REVIEW](#GITHUB-DEP-REVIEW) | the `security` job in `.github/workflows/ci.yml` | + +## Duplication gate {#CI-DESLOP} + +`.deslop.toml` at the repository root is the **single source of truth** for this +repo's duplication budget. It is committed and PR-reviewed, and +`[threshold] max_duplication_percent` is ratcheted **down** only — the same +one-way discipline the coverage and conformance gates use. + +`[defaults] exclude` drops paths during discovery, so excluded files are never +analysed and never contribute to the measured percentage +([deslop docs](https://deslop.live/docs/for-ai/)). Built-in defaults already +cover `node_modules`, `target`, `dist`, `__pycache__`, and any path containing +`generated`; the committed file adds only project-specific patterns on top — +benchmark fixtures, crate test trees, `examples/` at any depth, vendored Python +`site-packages`/`.venv`, the git-ignored `scratchpad/`, and the mirrored +`conformance/tests/` suite. Each of those is third-party or deliberately +repetitive code whose duplication must not gate the build. + +### Where the gate runs {#CI-DESLOP-GATE} + +`make lint` depends on `_lint_deslop`, which runs `deslop .` from the repository +root and fails the build on a non-zero exit. Because `make ci` is +`lint test build`, the gate runs locally and in CI through the same target — +there is no CI-only invocation and no second scorer. + +### Unpinned CLI {#CI-DESLOP-UNPINNED} + +The `deslop` CLI is **deliberately not pinned**. `scripts/install-deslop.sh` +installs `nimblesite/tap/deslop` with Homebrew, whose formula tracks the latest +release, and the same script is shared by `scripts/setup.sh` (local) and the CI +`Install deslop` step. The reason is agreement, not convenience: the CLI must +analyse the same corpus as the engine behind the editor's LSP/MCP panel, and a +pinned-stale CLI would make the gate disagree with what a developer sees in the +editor. + +The script is written for both environments: it loads `brew` from its known +install locations because CI steps run with `bash --noprofile --norc`, taps and +non-interactively trusts `nimblesite/tap` (Homebrew ≥ 6 otherwise prompts, which +deadlocks with CI's closed stdin), and appends the brew bin directory to +`$GITHUB_PATH` so the following `make lint` step resolves the binary. +`scripts/audit.sh` requires `deslop` on `PATH`, so a missing CLI is reported as a +missing tool rather than a silently skipped gate. + +## Coverage thresholds {#COVERAGE-THRESHOLDS-JSON} + +`coverage-thresholds.json` at the repository root is the **sole** source of code +coverage thresholds. There are no environment variables, no GitHub repository +variables, no thresholds in CI YAML, and no hardcoded fallbacks in the scripts. + +### Resolution {#COVERAGE-THRESHOLDS-JSON-RESOLUTION} + +| Key | Meaning | +|---|---| +| `default_threshold` | The threshold for any project without its own entry. | +| `projects..threshold` | The threshold for that project, overriding the default. | + +`coverage_threshold_for ` in `scripts/common.sh` is the reader for +every consumer that can source the shell helpers: it resolves the repository +root from its own path, loads the JSON, and prints `projects..threshold` +when the project is listed and `default_threshold` otherwise. +`scripts/test-rust.sh` calls it once per Rust crate before comparing measured +line coverage, and `scripts/test-nvim.sh` calls it for the `nvim` project. + +The Makefile's VSIX coverage recipe is the one exception: it is an inline make +recipe that never sources `scripts/common.sh`, so it reads +`projects.vsix.threshold` out of the same file with its own `python3 -c` +one-liner. That is a second **reader**, not a second **source** — both paths +resolve every threshold from `coverage-thresholds.json` and nothing else, which +is what the "sole source" rule above requires. + +### Enforcement {#COVERAGE-THRESHOLDS-JSON-ENFORCEMENT} + +`make test` always computes coverage and always enforces it. A project whose +measured percentage is below its threshold fails the run, and a project that +produced **no** coverage data fails too — that state means the tests died before +coverage could flush, and treating it as a pass would hide the failure. + +Thresholds **ratchet up only**. Lowering any `threshold` or `default_threshold` +value is forbidden; a project that cannot meet its number gets more tests. + +### Conformance block {#COVERAGE-THRESHOLDS-JSON-CONFORMANCE} + +The same file carries the `conformance` block (`threshold` and +`max_false_positives`), which is enforced by the real `python/typing` harness +rather than by the coverage scripts. Its policy — pass percentage up only, +false-positive ceiling down only, and no rule may be disabled to move either — +is normative in +[CHKARCH-CONFORMANCE](CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CONFORMANCE). + +## Committed editor directories {#GITIGNORE-RULES} + +`.gitignore`'s IDE/editor block ignores **per-user scratch** only — editor swap +and backup files (`*.swp`, `*.swo`, `*~`) and Sublime project/workspace files, +which are local to one machine and one person. + +Two editor directories are deliberately **not** ignored, because they are shared +dev tooling rather than personal state: + +| Directory | Status | Why it is committed | +|---|---|---| +| `.vscode/` | Tracked: `extensions.json`, `launch.json`, `settings.json` | Recommended extensions, the debug/launch configurations used to run the CLI and LSP, and workspace settings — so every contributor and every agent gets identical tooling without repeating setup instructions in prose. | +| `.idea/` | Not ignored; no files tracked today | JetBrains **shared** run configurations and code style are committable artifacts of the same kind. Leaving the path un-ignored means adding them is a normal commit, not a `.gitignore` edit. | + +Ignoring either directory wholesale would take the shared configuration with it, +so a change that adds `.vscode/` or `.idea/` to `.gitignore` is a regression, not +a cleanup. + +## Dependabot {#GITHUB-DEPENDABOT} + +`.github/dependabot.yml` keeps dependencies current without PR spam and without +burning the CI matrix on `main` for routine bumps. + +### Staging branch {#GITHUB-DEPENDABOT-STAGING} + +Every bump ends up on the long-lived `dependabot-upgrades` staging branch; +nothing reaches `main` unattended. + +- **Version updates** carry `target-branch: dependabot-upgrades`, so Dependabot + opens them against the staging branch. `ci.yml` and `codeql.yml` trigger on + `pull_request: [main]`, and that filter matches the PR **base**, so these PRs + run no build, test, or CodeQL. +- **Security updates** ignore `target-branch` — GitHub always opens them against + the default branch. `.github/workflows/dependabot-automerge.yml` therefore also + triggers on `main`, folds the security bump into the same staging branch, and + retires the PR, so a CVE bump never waits on a human either. +- **Grouping**: each ecosystem collapses all version bumps into one PR, with a + parallel `*-security` group (`applies-to: security-updates`) for CVE bumps. + Majors need no separate review PR because review happens once, on the + `dependabot-upgrades → main` consolidation PR. +- **Schedule**: `weekly`, one grouped PR per ecosystem. + +Ecosystems covered: `github-actions` (repo root), `cargo` (the Rust workspace at +the root), and `npm` for both `/vscode-extension` and `/website`. Keeping +`github-actions` current is what stops SHA-pinned actions from going stale — +action pinning itself is an external Shipwright requirement, cited at its own +call site. + +### Sweep workflow {#GITHUB-DEPENDABOT-SWEEP} + +`dependabot-automerge.yml` merges the incoming branch into `dependabot-upgrades` +with `-X theirs`, so successive bumps of the same lock file never conflict-stall: +the latest bump wins. Two independent gates are both required — the unforgeable +`dependabot[bot]` actor **and** a `dependabot/*` head branch. The workflow runs +git plumbing plus `gh pr close` only; it executes nothing from the merged tree, +and disables hooks via `core.hooksPath=/dev/null`. + +The trigger **must** stay `pull_request` and never become `pull_request_target`: +under `pull_request` a fork PR runs with a read-only token and no secrets, which +is what makes merging fork-controlled content here inert. + +The file lives at the repository root so it is present on both `main` and +`dependabot-upgrades` — for `pull_request`, the workflow is read from the PR's +base branch. + +### CI skips {#GITHUB-DEPENDABOT-CI-SKIP} + +`ci.yml`'s change-scope classifier turns every scope off for a +`dependabot[bot]` actor, and `codeql.yml` excludes the same actor. Those PRs are +swept into staging and discarded, so the expensive matrix runs once — on the +consolidation PR. + +### Prerequisite {#GITHUB-DEPENDABOT-PREREQ} + +The `dependabot-upgrades` branch must exist, cut from `main` **after** +`dependabot.yml` and `dependabot-automerge.yml` are on `main`, so the staging +branch carries the sweep workflow. + +## Code scanning {#GITHUB-CODE-SCANNING} + +`.github/workflows/codeql.yml` runs CodeQL static analysis and feeds GitHub +code-scanning alerts. It is separate from `ci.yml` because it needs +`security-events: write` and its own schedule, and it owns exactly one concern: +**vulnerable code**. `make lint` owns style/correctness and +[GITHUB-DEP-REVIEW](#GITHUB-DEP-REVIEW) owns vulnerable dependencies — no linter +plugin may re-cover CodeQL's ground. + +### Triggers {#GITHUB-CODE-SCANNING-TRIGGERS} + +| Trigger | Purpose | +|---|---| +| `pull_request` on `main` | Scans the diff. | +| `push` on `v*` tags | Scans the exact released SHA with the current query set — a release can ship code last scanned weeks earlier. | +| Weekly `schedule` | Re-scans with newly published queries even without a push. | + +The job is gated on `github.event.repository.visibility == 'public'`, because +SARIF upload requires GitHub Advanced Security on private repositories; a private +repo skips cleanly and self-enables when made public. Dependabot PRs are excluded +for the reason in [GITHUB-DEPENDABOT-CI-SKIP](#GITHUB-DEPENDABOT-CI-SKIP). + +### Language matrix {#GITHUB-CODE-SCANNING-LANGUAGES} + +The matrix is (languages actually in this repo) ∩ (languages CodeQL supports): +`rust` (checker/LSP), `javascript-typescript` (VS Code extension and website), +and `actions` (the workflow files themselves). All three use `build-mode: none` — +`ci.yml` already builds the Rust workspace. Python is supported by CodeQL but +deliberately excluded: the only Python here is deliberately malformed +type-checker fixtures under `conformance/`, `examples/`, and `tests/`, and +scanning them would drown real signal in fixture noise. + +The query suite is `security-extended`. + +### Query filters {#GITHUB-CODE-SCANNING-FILTERS} + +Query-level tuning that the `queries:` input cannot express lives in +`.github/codeql/codeql-config.yml`, wired in through the `init` step's +`config-file:`. It currently excludes exactly one id, +`actions/untrusted-checkout/medium`, for the Dependabot merge bot — that query +assumes a privileged workflow that checks out and runs a fork's build script, and +the sweep workflow does neither (see +[GITHUB-DEPENDABOT-SWEEP](#GITHUB-DEPENDABOT-SWEEP)). The dangerous +`actions/untrusted-checkout/high` variant — the `pull_request_target` case — +stays active, so a real misconfiguration is still caught. + +## Dependency review {#GITHUB-DEP-REVIEW} + +The `security` job in `.github/workflows/ci.yml` runs +`actions/dependency-review-action` with `fail-on-severity: high` and +`comment-summary-in-pr: on-failure`, and holds `pull-requests: write` so the +action can post its summary. + +This is the repository's **only** dependency vulnerability gate — there is no +`cargo-deny` or OSV gate, so there is no doubling up. It is separate from +[GITHUB-CODE-SCANNING](#GITHUB-CODE-SCANNING) (vulnerable code) and from +`make lint` (style and correctness): one owner per concern. + +It is PR-only by construction, because dependency review diffs the PR base +against its head — which matches `ci.yml`'s `pull_request`-only trigger. Bumps +that arrive through [GITHUB-DEPENDABOT](#GITHUB-DEPENDABOT) are reviewed here on +the consolidation PR, where the full matrix runs. diff --git a/docs/specs/VSIX-SPEC.md b/docs/specs/VSIX-SPEC.md index 0118e5a4..f635d01a 100644 --- a/docs/specs/VSIX-SPEC.md +++ b/docs/specs/VSIX-SPEC.md @@ -31,14 +31,32 @@ flowchart LR ## Extension Structure {#VSIX-EXTENSION-STRUCTURE} +`src/` is flat and file-per-concern (63 `.ts` files); the groups below are naming +prefixes, not directories. Files carry an `// Implements [ID]` header pointing at +the spec section they realise. + ``` vscode-extension/ ├── src/ -│ ├── extension.ts # Activation, LanguageClient setup, command registration -│ └── dap-proxy.ts # DebugAdapterProxy (TypeScript, in-process) -├── package.json # Commands, settings, keybindings, debugger contribution -├── tsconfig.json -└── .vscode-test.mjs +│ ├── extension.ts # Activation entry: wires every group below +│ ├── lsp-client.ts, lsp-document-selector.ts, # Client construction, selector, +│ │ lsp-trace.ts, subprocess-mode.ts # trace channel, CLI fallback +│ ├── store*.ts, reactive-refresh.ts # THE single Signals state container +│ ├── logger.ts # Output channel + log file [VSIX-OUTPUT-CHANNELS] +│ ├── result.ts, timeouts.ts, progress-ops.ts, shipwright-runtime.ts +│ ├── configuration-editor*.ts # Config editor [VSIX-CONFIGURATION-EDITOR-FILES] +│ ├── dap-proxy.ts, dap-evaluate.ts, dap-output.ts, debug-adapter.ts +│ ├── profiler*.ts, profile-server.ts # CPU profiler: webview, flamegraph, decorations +│ ├── memory-*.ts # Memory profiler: dashboard, ref graph, autopilot +│ ├── process-*.ts, processes-state.ts, # Process Explorer, Module Explorer, +│ │ module-explorer*.ts, info-panel.ts # sidebar info panel [EXTACT-INFO] +│ ├── test-explorer.ts, # TestController [VSIX-TEST-EXPLORER-INTEGRATION] +│ │ coverage-decorations.ts # + coverage gutter decorations +│ └── test/ # runTest.ts, suite/*.test.ts, fixtures/, real-world/ +├── package.json # Commands, settings, keybindings, views, debuggers +├── tsconfig.json, eslint.config.mjs, eslint-rules.cjs, .vscode-test.mjs +├── shipwright.json # Release manifest (scripts/sync-shipwright-manifest.mjs) +└── scripts/, resources/, images/ # Build/staging helpers, activity-bar icon, art ``` --- @@ -74,19 +92,35 @@ client.start(); ### `package.json` contribution {#VSIX-COMMANDS-PACKAGE-JSON-CONTRIBUTION} -```json -"commands": [ - { "command": "basilisk.restartServer", "title": "Basilisk: Restart Language Server" }, - { "command": "basilisk.showOutput", "title": "Basilisk: Show Output" }, - { "command": "basilisk.openConfigurationEditor", "title": "Basilisk: Open Configuration Editor" }, - { "command": "basilisk.organizeImports", "title": "Basilisk: Organize Imports" }, - { "command": "basilisk.runTests", "title": "Basilisk: Run Tests" }, - { "command": "basilisk.runTestFile", "title": "Basilisk: Run Tests in Current File" }, - { "command": "basilisk.debugTest", "title": "Basilisk: Debug Test" }, - { "command": "basilisk.debugFile", "title": "Basilisk: Debug Current File" }, - { "command": "basilisk.toggleTypeBreakpoints", "title": "Basilisk: Toggle Type Mismatch Breakpoints" } -] -``` +`contributes.commands` holds the **client-side** commands only — every entry +carries `"category": "Basilisk"`. Server-advertised commands — `basilisk.runTests`, +`basilisk.runTestFile`, `basilisk.debugTest`, `basilisk.runTestsCoverage` and the +rest declared in `crates/basilisk-common/src/lib.rs` — are deliberately **absent**: +`vscode-languageclient` registers them from `executeCommandProvider`, per the rule +above. + +| Group | Commands (`basilisk.` prefix elided) | +|---|---| +| Server & status | `restartServer`, `showOutput`, `statusMenu`, `openWalkthrough` | +| Configuration editor | `openConfigurationEditor`, `editConfig` | +| Fixes & adoption | `organizeImports`, `fixFile`, `fixFileAll`, `fixWorkspace`, `fixWorkspaceAll`, `adoptFile`, `adoptWorkspace`, `unadoptFile` | +| uv | `uv.sync`, `uv.add`, `uv.addDev`, `uv.remove`, `uv.lock`, `uv.createEnv` | +| Module Explorer | `refreshModuleExplorer`, `sortModuleExplorer`, `toggleModuleExplorerView`, `filterModuleExplorer`, `copyImportPath`, `copyQualifiedName` | +| CPU profiler | `profileStart`, `profileStop`, `profileSnapshot`, `profileAttachToDebug`, `profileShowResults`, `profileCurrentFileCpu`, `profileProcess` | +| Memory profiler | `memoryMenu`, `memoryStart`, `memorySnapshot`, `memoryStop`, `memoryDiff`, `memoryGcCollect`, `memoryReferences`, `trackMemoryCurrentFile`, `memoryTrackProcess` | +| Process Explorer | `refreshProcesses`, `sortProcesses`, `groupProcesses`, `filterProcesses`, `copyProcessPid`, `revealProcessScript` | +| Info panel | `info.runAction` | + +Palette visibility is narrowed in `contributes.menus.commandPalette`: + +- `openConfigurationEditor` appears only under `basilisk.configurationEditorSupported` (the same context key gates its `enablement`, as it does `editConfig`'s); +- `editConfig`, `info.runAction`, `profileProcess`, `memoryTrackProcess`, `copyProcessPid` and `revealProcessScript` are `"when": false` — context-menu / view-title actions only; +- the seven in-session memory commands — `memoryMenu`, `memoryStart`, + `memorySnapshot`, `memoryDiff`, `memoryGcCollect`, `memoryReferences`, + `memoryStop` — are gated on `basilisk.debugging`, so the palette offers them + only while a debug session is live. `trackMemoryCurrentFile` is deliberately + ungated: it *starts* the tracked session, so gating it on `basilisk.debugging` + would make it unreachable. (`memoryTrackProcess` is `"when": false`, above.) --- @@ -165,15 +199,28 @@ impact makes the consequence visible before apply. ### Implementation files and tests {#VSIX-CONFIGURATION-EDITOR-FILES} -Implementation is split into focused files under 500 LOC: +Implementation lives in `vscode-extension/src/`, split into focused files each +kept under the repository's 500-LOC ceiling: - `configuration-editor.ts` — panel lifecycle and intent routing; +- `configuration-editor-transport.ts` — the LSP seam: capability probe, the + `ConfigurationEditorTransport` request wrapper, and workspace-root selection + (re-exported from `configuration-editor.ts`, so callers still import the + editor's public surface from one module); +- `configuration-editor-registration.ts` — capability-gated command registration + (`basilisk.openConfigurationEditor` / `basilisk.editConfig` + context key); - `configuration-editor-document.ts` — CSP HTML document; - `configuration-editor-model.ts` — generated wire DTOs/projections; - `configuration-editor-state.ts` — store actions and immutable state; - `configuration-editor-intents.ts` — runtime decoder for untrusted messages; -- `configuration-editor-styles.ts` and `configuration-editor-script-*.ts` — - dependency-free visual/runtime fragments. +- `configuration-editor-errors.ts` — structured error routing, including + revision-conflict classification; +- `configuration-editor-typeshed.ts` — native typeshed controls and the + read-only license document provider + ([LSPCFGED-TYPESHED](LSP-CONFIGURATION-EDITOR-SPEC.md#LSPCFGED-TYPESHED)); +- `configuration-editor-styles.ts` and `configuration-editor-script*.ts` + (`-core`, `-events`, `-render`, `-typeshed`, assembled by + `configuration-editor-script.ts`) — dependency-free visual/runtime fragments. Focused VSIX tests exercise all four persisted severities plus entry removal, tag/all selectors, exact preview/apply identity, paged diff --git a/docs/specs/WEBSITE-ERROR-PAGES-SPEC.md b/docs/specs/WEBSITE-ERROR-PAGES-SPEC.md index c868cd8e..26203169 100644 --- a/docs/specs/WEBSITE-ERROR-PAGES-SPEC.md +++ b/docs/specs/WEBSITE-ERROR-PAGES-SPEC.md @@ -16,7 +16,7 @@ pages never drift from the diagnostics the binary emits. it — on each rule module, and writes `website/src/_data/rules.json`: ```json -{ "code", "severity", "summary", "summaryHtml", "body": [{type:"text"|"code"}], "group", "docsUrl" } +{ "code", "severity", "summary", "summaryHtml", "body": [{type:"text"|"code"}], "group", "docsUrl", "references": [{label, url}] } ``` `docsUrl` is read from the rule's own `docs_url` literal, so the page URL and CLI @@ -24,6 +24,29 @@ link are identical. The same data drives the `/docs/rules/` reference table and the headline counts (`_data/ruleStats.js`, `_data/ruleGroups.js`) — prose, table, and pages share one source. +### Canonical references {#WEBSITE-ERROR-PAGES-REFERENCES} + +Every record carries `references` — the canonical upstream documentation for +the diagnostic, in order: the rule's chapter of the maintained +[typing spec](https://typing.python.org/en/latest/spec/index.html) (chapter +titles and filenames mirror that index verbatim), then the PEP(s) that chapter +incorporates merged with every `PEP NNN` the rule's own doc comment cites +(linked to `https://peps.python.org/pep-NNNN/`), then any language-reference +link for rules governed by Python semantics rather than the typing spec (the +`names_*` unbound/undefined checks link the execution model's Naming and +binding section). The prefix→chapter and prefix→PEP tables live in +`scripts/gen_rules_reference.py` (`SPEC_CHAPTER_BY_PREFIX`, `PEPS_BY_PREFIX`); +PEP links are labelled `PEP NNN` only, so no title can drift from +peps.python.org. `inline_html` additionally links every `PEP NNN` mention in +rendered summary/body prose. Opt-in house rules (`BSK-` codes) map through +`REFERENCE_PREFIX_BY_BSK_CODE` to the chapter/PEPs documenting the mechanism +they police (annotation rules → Type annotations; BSK-0152 → PEP 561; +BSK-0011/0012 → PEP 621; BSK-0013 → uv's lockfile docs), plus any PEP their +own docs cite (BSK-0025 → PEP 698). The suppression rules (BSK-0060..0063) +police Basilisk's own directives and deliberately list nothing. `error.njk` +renders the list as a "Canonical documentation" section on every +`/errors//` page. + ### Drift guard {#WEBSITE-ERROR-PAGES-DRIFT} The CI website job regenerates the data and `diff`s it against the committed @@ -36,8 +59,9 @@ re-runs the guard. A new rule cannot ship without its page. `website/src/errors/error.njk` paginates `rules` (size 1) to emit `/errors/{{ code }}/` for every record. Each page shows the code, a severity badge, the summary, the doc-comment body (text + code blocks), a worked -`basilisk check` screenshot when one exists, how-to-handle guidance, and the -canonical `docsUrl`. `website/src/errors/index.njk` is a grouped, browsable +`basilisk check` screenshot when one exists, the canonical-documentation links +([WEBSITE-ERROR-PAGES-REFERENCES]), how-to-handle guidance, and the canonical +`docsUrl`. `website/src/errors/index.njk` is a grouped, browsable directory of all codes. Pages deliberately omit `eleventyNavigation` so the 160 entries never flood the docs sidebar. diff --git a/docs/specs/ZED-SPEC.md b/docs/specs/ZED-SPEC.md index d868097b..6dfdb956 100644 --- a/docs/specs/ZED-SPEC.md +++ b/docs/specs/ZED-SPEC.md @@ -66,7 +66,12 @@ graph TB basilisk-zed/ extension.toml Cargo.toml - src/lib.rs + src/ + lib.rs # Thin zed_extension_api glue — the WASM entry points + logic.rs # Pure logic, zero zed_extension_api imports (host-testable) + logic_tests.rs # Unit tests for logic.rs; #[path]-included as `mod tests` + tests/ + fixtures/ # Python sample files (clean, type_error, completions) languages/ python/ config.toml diff --git a/models/configuration_editor.td b/models/configuration_editor.td index 2e997888..80effa84 100644 --- a/models/configuration_editor.td +++ b/models/configuration_editor.td @@ -26,20 +26,13 @@ union TagKind { Descriptive } -# The Typeshed acquisition keys are a closed allowlist. The wire never accepts -# an arbitrary TOML key or an untyped value ([LSPCFGED-TYPESHED]). +# The Typeshed keys are a closed allowlist — the whole surface is three keys, +# every one of them a string (a path or a full commit SHA). The wire never +# accepts an arbitrary TOML key or an untyped value ([LSPCFGED-TYPESHED]). union TypeshedSettingKey { TypeshedPath TypeshedCommit - TypeshedUrl - TypeshedCachePath - TypeshedCache - TypeshedVerify -} - -union TypeshedSettingValue { - Text { value: String } - Boolean { value: Bool } + TypeshedStorePath } # The only six things the editor can ask for: four rule/tag mutations plus @@ -50,70 +43,49 @@ union EditorMutation { RemoveRule { code: RuleCode } SetTag { tag: RuleTag, severity: RuleSeverity } RemoveTag { tag: RuleTag } - SetTypeshedSetting { key: TypeshedSettingKey, value: TypeshedSettingValue } + SetTypeshedSetting { key: TypeshedSettingKey, value: String } RemoveTypeshedSetting { key: TypeshedSettingKey } } -# The ACTIVE source carries the value that defines it, so "latest with a pin" -# and "a pinned commit plus a custom folder" cannot be expressed at all -# ([LSPCFGED-TYPESHED]). +# The ACTIVE source carries the value that defines it, so "a pinned commit plus +# a custom folder" cannot be expressed at all. There are exactly two sources: +# there is no "track latest" source ([LSPCFGED-TYPESHED]). union TypeshedSource { - Latest ExactCommit { commit: String } CustomFolder { path: String } } -# Download policy exists only for a downloaded source: a user-managed folder -# downloads nothing, so it has none. -type TypeshedDownloadPolicy { - reuseDownloads: Bool - verifyContent: Bool - archiveUrl: Option - cacheFolder: Option -} - +# Downloading is the only long-running state, and it is always user-invoked +# ([LSPCFGED-TYPESHED-DOWNLOAD]). NoSource = the selected source is not on this +# machine, so analysis does not run. union TypeshedLifecycle { - Acquiring + Downloading Ready - Blocked + NoSource } union TypeshedAction { - PinCurrent - AcquireFresh + DownloadLatest + DownloadPinned ViewLicense } +# The active source is the whole trust story (custom = user-managed, bundled = +# build-vetted, exact commit = attested at download, re-proven offline), so +# there are no separate transport or provenance fields ([STUBRES-TYPESHED-WARN]). union TypeshedActiveSource { Custom ExactCommit - Latest Bundled } -union TypeshedTransport { - CustomPath - EmbeddedZip - Codeload - Mirror -} - union TypeshedLicenseStatus { - Acquiring Unavailable Approved Changed NotSupplied } -union TypeshedProvenance { - Pending - GithubTlsAttested - Unverified - BundleVetted - UserManaged -} - union TypeshedWarningSeverity { Advisory High @@ -127,24 +99,20 @@ type TypeshedWarningState { type TypeshedStatusState { lifecycle: TypeshedLifecycle - blockedReason: Option + noSourceReason: Option activeSource: Option commitIdentity: Option - transport: Option licenseStatus: TypeshedLicenseStatus - provenance: TypeshedProvenance - signedRelease: Bool warnings: List } # Everything the editor needs, and nothing it can misrender: the one active -# source, the download policy that source has (none for a custom folder), the -# commit PinCurrent would write when pinning is possible, and whether a license -# document exists to open. Labels are client copy, not server state. +# source, the store folder pins resolve from (none for a custom folder), and +# whether a license document exists to open. Labels are client copy, not server +# state. type TypeshedConfigurationState { source: TypeshedSource - downloads: Option - pinnableCommit: Option + storeFolder: Option licenseAvailable: Bool status: TypeshedStatusState } @@ -286,8 +254,8 @@ type ConfigurationPreview { type TypeshedSettingChange { key: TypeshedSettingKey - before: Option - after: Option + before: Option + after: Option } # rootUri + previewId fully identify the cached preview, which already pins @@ -345,8 +313,10 @@ type TypeshedLicenseDocument { readOnly: Bool } +# Downloads return the refreshed snapshot immediately (lifecycle Downloading); +# completion arrives as TypeshedStatusChanged + ConfigurationChanged. No action +# returns a preview — a download is not a configuration edit. union TypeshedActionResult { - Preview { preview: ConfigurationPreview } Snapshot { snapshot: ConfigurationSnapshot } License { license: TypeshedLicenseDocument } } diff --git a/scripts/audit.sh b/scripts/audit.sh index 738803c6..862acc0d 100755 --- a/scripts/audit.sh +++ b/scripts/audit.sh @@ -23,6 +23,27 @@ require_py() { fi } +# basilisk.nvim's real floor is Neovim 0.11: lua/basilisk/health.lua hard-errors +# below it and lua/basilisk/lsp.lua drives the 0.11-only vim.lsp.config()/ +# vim.lsp.enable(). A presence-only check let an older nvim pass this gate and +# then fail the suite, so ask Neovim itself with the same has() predicate +# health.lua uses. `--clean` keeps a user's config out of the answer. +NVIM_MIN=0.11 + +require_nvim() { + if ! command -v nvim &>/dev/null; then + echo -e " ${RED}✗ MISSING: nvim — Install Neovim ${NVIM_MIN}+: https://neovim.io${RESET}"; MISSING=1 + return + fi + if ! nvim --clean --headless \ + -c "lua io.write(vim.fn.has('nvim-${NVIM_MIN}'))" -c quit 2>/dev/null | grep -q 1; then + echo -e " ${RED}✗ TOO OLD: $(nvim --version | head -1) — basilisk.nvim requires Neovim ${NVIM_MIN}+: https://neovim.io${RESET}" + MISSING=1 + return + fi + echo -e " ${GREEN}✓ nvim (>= ${NVIM_MIN})${RESET}" +} + require_cmd cargo "Install Rust: https://rustup.rs" require_cmd cargo-llvm-cov "Install: cargo install cargo-llvm-cov" require_cmd cargo-audit "Install: cargo install cargo-audit --locked" @@ -30,7 +51,7 @@ require_cmd node "Install Node.js 20+: https://nodejs.org" require_cmd npm "Bundled with Node.js" require_cmd python3 "Install Python 3.12: https://python.org" require_cmd ruff "Install: pip install ruff" -require_cmd nvim "Install Neovim 0.10+: https://neovim.io" +require_nvim require_cmd deslop "Install: scripts/install-deslop.sh" require_py debugpy "Install: pip install debugpy" diff --git a/scripts/check-dependency-shape.sh b/scripts/check-dependency-shape.sh new file mode 100755 index 00000000..fa02d722 --- /dev/null +++ b/scripts/check-dependency-shape.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Implements [TYPESHEDRT-SEGREGATION]: "the checker never downloads" is a +# property of the BUILD, not a code-review convention. The analysis crates +# (basilisk-stubs, basilisk-checker) must resolve to a dependency graph that +# contains no HTTP client and no basilisk-typeshed-fetch — downloading exists +# only behind explicit user actions (`basilisk typeshed download`, the +# editor's Download buttons). +set -euo pipefail +cd "$(dirname "$0")/.." + +fail() { + echo "DEPENDENCY SHAPE VIOLATION: $1" >&2 + exit 1 +} + +# Match crate names at line start in `cargo tree --prefix none` output. +forbid_in_graph() { + local package="$1" pattern="$2" reason="$3" + if cargo tree -p "$package" -e normal --prefix none | grep -Eq "$pattern"; then + fail "$reason (matched '$pattern' in ${package}'s resolved graph)" + fi +} + +for analysis_crate in basilisk-stubs basilisk-checker; do + forbid_in_graph "$analysis_crate" \ + '^(ureq|reqwest|hyper|curl|isahc|attohttpc) ' \ + "$analysis_crate must carry no HTTP client — the analysis path is offline by construction [STUBRES-TYPESHED-OFFLINE]" + forbid_in_graph "$analysis_crate" \ + '^basilisk-typeshed-fetch ' \ + "$analysis_crate must not link the download component [TYPESHEDRT-SEGREGATION]" +done + +# The download component is the ONE place an HTTP client is allowed; make its +# presence explicit so a refactor that silently drops the client (leaving a +# download command that can never work) is caught too. +if ! cargo tree -p basilisk-typeshed-fetch -e normal --prefix none | grep -Eq '^ureq '; then + fail "basilisk-typeshed-fetch must carry its own HTTPS client [STUBRES-TYPESHED-DOWNLOAD]" +fi + +echo "dependency shape OK: analysis crates are offline; downloads live only in basilisk-typeshed-fetch" diff --git a/scripts/gen_conformance_reference.py b/scripts/gen_conformance_reference.py index 4bd3b970..80ba16ed 100644 --- a/scripts/gen_conformance_reference.py +++ b/scripts/gen_conformance_reference.py @@ -29,17 +29,19 @@ import sys from pathlib import Path +import gen_readmes + ROOT = Path(__file__).resolve().parents[1] REPORT = ROOT / "website" / "src" / "_data" / "conformance_report.json" BENCH_STATUS_DIR = ROOT / "benchmarks" / "status" +# Every published README quotes the same score, but only ONE file per language +# is authored: the READMEs are generated from these sources ([README]), so the +# markers are stamped here and `gen_readmes.py` propagates them to GitHub, the +# VSIX (Marketplace + Open VSX), and PyPI. TARGETS = ( - ROOT / "README.md", - ROOT / "README.zh.md", + ROOT / "docs" / "readme" / "README.src.md", + ROOT / "docs" / "readme" / "README.zh.src.md", ROOT / "docs" / "specs" / "CHECKER-ARCHITECTURE-SPEC.md", - # The VS Code marketplace READMEs boast the same score — keep them in lock - # step so the published listing can never quote a stale number. - ROOT / "vscode-extension" / "README.md", - ROOT / "vscode-extension" / "README.zh.md", ) # The checkers whose median cold time the README bench table quotes. Key is the @@ -200,7 +202,9 @@ def main(argv: list[str]) -> int: print(f" - {path.relative_to(ROOT)}", file=sys.stderr) return 1 print(" conformance docs up to date.") - return 0 + # A stamped source is only half the contract — the generated READMEs + # must carry the same figures ([README-STAMPED]). + return gen_readmes.main(["gen_readmes.py", "--check"]) if stale: print(f" Stamped conformance {vals['score']} (commit {vals['short']}) into:") @@ -208,7 +212,9 @@ def main(argv: list[str]) -> int: print(f" - {path.relative_to(ROOT)}") else: print(" conformance docs already up to date.") - return 0 + # The published READMEs are rendered from the stamped sources ([README]); + # regenerating here keeps a stamp from ever landing without them. + return gen_readmes.main(["gen_readmes.py"]) if __name__ == "__main__": diff --git a/scripts/gen_readmes.py b/scripts/gen_readmes.py new file mode 100755 index 00000000..354e97de --- /dev/null +++ b/scripts/gen_readmes.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +# Implements [README]. See docs/specs/DOCS-README-SPEC.md +"""Render every published README from the single authored source. + +Basilisk's front page is published to three storefronts — GitHub, the VS Code +Marketplace / Open VSX (one VSIX, one file), and PyPI. They used to be three +hand-maintained files, so they drifted ([README-PURPOSE]). Now +`docs/readme/README.src.md` (and its Chinese mirror) is the only authored copy, +and every published README is generated from it: identical except for one +paragraph saying which artifact the reader is looking at ([README-IDENTITY]). + +Usage: + python3 scripts/gen_readmes.py # rewrite the generated READMEs + python3 scripts/gen_readmes.py --check # CI: fail if any is stale +""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_DIR = ROOT / "docs" / "readme" + +REPO_BLOB = "https://github.com/Nimblesite/Basilisk/blob/main" +REPO_RAW = "https://raw.githubusercontent.com/Nimblesite/Basilisk/main" + +GENERATED_BANNER = ( + "\n" +) + + +@dataclass(frozen=True) +class Target: + """One storefront the README is published to ([README-TARGETS]).""" + + key: str + output: Path + alt_lang_href: str + + +@dataclass(frozen=True) +class Source: + """One authored README and the targets rendered from it ([README-SOURCE]).""" + + path: Path + targets: tuple[Target, ...] + + +VSIX_README_EN = f"{REPO_BLOB}/vscode-extension/README.md" +VSIX_README_ZH = f"{REPO_BLOB}/vscode-extension/README.zh.md" + +SOURCES = ( + Source( + path=SOURCE_DIR / "README.src.md", + targets=( + Target("github", ROOT / "README.md", "README.zh.md"), + Target("vscode", ROOT / "vscode-extension" / "README.md", VSIX_README_ZH), + # The wheel listing is English-only; point its switch at the + # repository's Chinese front page rather than a page PyPI lacks. + Target("pypi", ROOT / "README-pypi.md", f"{REPO_BLOB}/README.zh.md"), + ), + ), + Source( + path=SOURCE_DIR / "README.zh.src.md", + targets=( + Target("github", ROOT / "README.zh.md", "README.md"), + Target( + "vscode", ROOT / "vscode-extension" / "README.zh.md", VSIX_README_EN + ), + ), + ), +) + +VARIANT_RE = re.compile( + r"[ \t]*\n(?P.*?)[ \t]*\n", + re.S, +) +# Markdown `](path)` / `](path "title")` and HTML `src="path"` / `href="path"`. +MD_LINK_RE = re.compile(r"\]\((?P[^)\s]+)(?P[^)]*)\)") +HTML_ATTR_RE = re.compile(r'(?P\b(?:src|href)=")(?P[^"]+)"') +IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}) + + +class GenerationError(RuntimeError): + """A source defect that must fail the build rather than ship broken.""" + + +def apply_variants(text: str, key: str) -> str: + """Keep each `` block only for the targets that list it. + + Transform 1 of [README-RENDER]. + """ + + def resolve(match: re.Match[str]) -> str: + keys = match["keys"].split(",") + return match["body"] if key in keys else "" + + return VARIANT_RE.sub(resolve, text) + + +def _absolute(url: str) -> str: + """Absolutise one repo-relative link for a storefront outside the repo.""" + path = url.split("#", 1)[0].rstrip("/") + if not (ROOT / path).exists(): + raise GenerationError( + f"relative link `{url}` resolves to no file in the repository — " + "a published README cannot carry it" + ) + base = REPO_RAW if Path(path).suffix.lower() in IMAGE_SUFFIXES else REPO_BLOB + return f"{base}/{url}" + + +def _is_relative(url: str) -> bool: + return not url.startswith(("http://", "https://", "#", "mailto:", "//")) + + +def absolutise_links(text: str) -> str: + """Rewrite every repo-relative link/image to its canonical GitHub URL. + + Transform 3 of [README-RENDER]. Only the `github` target keeps relative + links, because only there does the rendered file sit at the repository root. + """ + + def markdown(match: re.Match[str]) -> str: + url = match["url"] + if not _is_relative(url): + return match[0] + return f"]({_absolute(url)}{match['rest']})" + + def html(match: re.Match[str]) -> str: + url = match["url"] + if not _is_relative(url): + return match[0] + return f'{match["attr"]}{_absolute(url)}"' + + return HTML_ATTR_RE.sub(html, MD_LINK_RE.sub(markdown, text)) + + +def render(source_text: str, source_name: str, target: Target) -> str: + """Render one target: variants, tokens, then link absolutisation. + + The three [README-RENDER] transforms, in the order the spec fixes. Token + substitution is transform 2; `{{altLangHref}}` is a per-target expression of + one statement, not content ([README-IDENTITY]). + """ + body = apply_variants(source_text, target.key) + body = body.replace("{{altLangHref}}", target.alt_lang_href) + if target.key != "github": + body = absolutise_links(body) + return GENERATED_BANNER.format(source=source_name) + body + + +def strip_authoring_header(text: str) -> str: + """Drop the source's own `` preamble.""" + if not text.startswith("") + len("-->\n") + return text[end:] + + +@dataclass(frozen=True) +class Variant: + """One `` block: its body and the targets it renders for.""" + + keys: tuple[str, ...] + body: str + + @property + def marker(self) -> str: + """The opening marker, for naming the block in an error message.""" + return f"" + + def is_identity_paragraph(self) -> bool: + """One blockquote line saying which artifact the reader is looking at. + + The bold opener is load-bearing, not cosmetic: [README-IDENTITY] allows a + variant block to say *which* artifact this is and nothing else, and every + identity paragraph in `docs/readme/` opens `> **You are reading …`. + Accepting any `> ` line would let target-specific prose ride along inside + a blockquote — exactly the per-target divergence this guard exists to + stop — so the bold form is required. + """ + lines = [line for line in self.body.splitlines() if line.strip()] + return len(lines) == 1 and lines[0].startswith("> **") + + +def variants(source_text: str) -> tuple[Variant, ...]: + """Every `` block in the source, in document order.""" + return tuple( + Variant(tuple(match["keys"].split(",")), match["body"]) + for match in VARIANT_RE.finditer(source_text) + ) + + +def comparable(source_text: str) -> str: + """The source with every variant block removed. + + What remains is shared by every target verbatim: the language-switch href is + still its token rather than a per-target URL and links are still relative, so + this is the text that may not vary between storefronts ([README-IDENTITY]). + """ + return VARIANT_RE.sub("", source_text).strip() + + +def _assert_identity_variant( + variant: Variant, declared: frozenset[str], claimed: frozenset[str], name: str +) -> None: + """One block must be an identity paragraph for a declared, unclaimed target.""" + if unknown := [key for key in variant.keys if key not in declared]: + raise GenerationError( + f"{name}: {variant.marker} renders for no target of this source " + f"({', '.join(unknown)}) — dead content, see [README-IDENTITY]" + ) + if not variant.is_identity_paragraph(): + raise GenerationError( + f"{name}: {variant.marker} is not a single identity paragraph — that " + "one line is all a target may vary by, see [README-IDENTITY]" + ) + if repeated := [key for key in variant.keys if key in claimed]: + raise GenerationError( + f"{name}: target `{repeated[0]}` carries a second variant block — one " + "identity paragraph per target, see [README-IDENTITY]" + ) + + +def assert_only_identity_differs( + source_text: str, keys: tuple[str, ...], source_name: str +) -> None: + """[README-IDENTITY]: targets may differ by the identity paragraph alone. + + A `` block is the only thing that can make content target-specific, + so the rule is enforced on the blocks themselves rather than on a text diff + that has to guess which lines are the identity: everything outside them is + shared verbatim, and every block is one identity paragraph claimed by exactly + one declared target. + """ + if not comparable(source_text): + raise GenerationError(f"{source_name}: rendered nothing") + declared = frozenset(keys) + claimed: set[str] = set() + for variant in variants(source_text): + _assert_identity_variant(variant, declared, frozenset(claimed), source_name) + claimed.update(variant.keys) + if missing := sorted(declared - claimed): + raise GenerationError( + f"{source_name}: target(s) {', '.join(missing)} carry no identity " + "paragraph — every storefront must say which artifact it is, see " + "[README-IDENTITY]" + ) + + +def outputs() -> list[tuple[Path, str]]: + """Every generated README path with its rendered content.""" + results: list[tuple[Path, str]] = [] + for source in SOURCES: + raw = strip_authoring_header(source.path.read_text(encoding="utf-8")) + assert_only_identity_differs( + raw, + tuple(target.key for target in source.targets), + source.path.name, + ) + results.extend( + (target.output, render(raw, source.path.name, target)) + for target in source.targets + ) + return results + + +def main(argv: list[str]) -> int: + """Write every generated README, or with `--check` enforce [README-DRIFT].""" + check = "--check" in argv[1:] + try: + generated = outputs() + except GenerationError as error: + print(f"gen_readmes: {error}", file=sys.stderr) + return 2 + + stale: list[Path] = [] + for path, content in generated: + current = path.read_text(encoding="utf-8") if path.exists() else None + if current == content: + continue + if check: + stale.append(path) + else: + path.write_text(content, encoding="utf-8") + print(f"wrote {path.relative_to(ROOT)}") + + if stale: + listing = ", ".join(str(path.relative_to(ROOT)) for path in stale) + print( + f"gen_readmes: stale generated README(s): {listing}\n" + " Edit docs/readme/README.src.md (or its .zh source), then run:\n" + " python3 scripts/gen_readmes.py", + file=sys.stderr, + ) + return 1 + if check: + print("READMEs are in sync with docs/readme/") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/gen_rules_reference.py b/scripts/gen_rules_reference.py index ad146754..1baf725a 100644 --- a/scripts/gen_rules_reference.py +++ b/scripts/gen_rules_reference.py @@ -44,9 +44,116 @@ DEFAULT_DATA_OUT = ROOT / "website" / "src" / "_data" / "rules.json" CONFORMANCE_STATUS = ROOT / "conformance" / "conformance_status.csv" ERRORS_BASE_URL = "https://www.basilisk-python.dev/errors" +TYPING_SPEC_BASE_URL = "https://typing.python.org/en/latest/spec" + +# [WEBSITE-ERROR-PAGES-REFERENCES]: canonical documentation for every code. +# Each code-name prefix maps to its chapter of the maintained typing spec +# (https://typing.python.org/en/latest/spec/ — titles and filenames taken from +# that index verbatim). Conformance categories are named after these chapters +# upstream; the trailing entries cover Basilisk's general soundness rules whose +# prefix is not a conformance category. +SPEC_CHAPTER_BY_PREFIX = { + "aliases": ("Type aliases", "aliases.html"), + "annotations": ("Type annotations", "annotations.html"), + "callables": ("Callables", "callables.html"), + "classes": ("Class type assignability", "class-compat.html"), + "constructors": ("Constructors", "constructors.html"), + "dataclasses": ("Dataclasses", "dataclasses.html"), + "directives": ("Type checker directives", "directives.html"), + "enums": ("Enumerations", "enums.html"), + "exceptions": ("Exceptions", "exceptions.html"), + "generics": ("Generics", "generics.html"), + "historical": ("Historical and deprecated features", "historical.html"), + "literals": ("Literals", "literal.html"), + "namedtuples": ("Named Tuples", "namedtuples.html"), + "narrowing": ("Type narrowing", "narrowing.html"), + "overloads": ("Overloads", "overload.html"), + "protocols": ("Protocols", "protocol.html"), + "qualifiers": ("Type qualifiers", "qualifiers.html"), + "specialtypes": ("Special types in annotations", "special-types.html"), + "tuples": ("Tuples", "tuples.html"), + "typeddicts": ("Typed dictionaries", "typeddict.html"), + "typeforms": ("Type forms", "type-forms.html"), + "assignment": ("Type system concepts", "concepts.html"), + "calls": ("Callables", "callables.html"), + "dict": ("Type system concepts", "concepts.html"), + "imports": ("Distributing type information", "distributing.html"), + "match": ("Type narrowing", "narrowing.html"), + "returns": ("Type system concepts", "concepts.html"), + "version": ("Generics", "generics.html"), +} + +# The accepted typing PEPs each spec chapter incorporates — every rule under +# the prefix links these on top of any PEP its own doc comment cites. Numbers +# only; labels stay "PEP NNN" so nothing here can drift from peps.python.org. +PEPS_BY_PREFIX = { + "aliases": (484, 613, 695), + "annotations": (3107, 484, 526), + "callables": (484, 612, 692), + "classes": (484, 526, 698), + "constructors": (484,), + "dataclasses": (557, 681), + "directives": (484, 702), + "enums": (435,), + "generics": (484, 612, 646, 673, 695, 696), + "historical": (484,), + "literals": (586, 675), + "namedtuples": (484,), + "narrowing": (647, 742), + "overloads": (484,), + "protocols": (544,), + "qualifiers": (526, 591, 593), + "specialtypes": (484,), + "tuples": (484, 646), + "typeddicts": (589, 655, 705, 728), + "typeforms": (747,), + "assignment": (484,), + "calls": (484,), + "imports": (561,), + "match": (634,), + "returns": (484,), + "packaging": (621,), +} + +# Rules governed by something other than the typing spec link that authority +# instead: the Python language reference, or a tool's own documentation. +LANGUAGE_REFS_BY_PREFIX = { + "names": ( + { + "label": "Python language reference: Naming and binding", + "url": "https://docs.python.org/3/reference/executionmodel.html#naming-and-binding", + }, + ), + "uv": ( + { + "label": "uv: Locking and syncing", + "url": "https://docs.astral.sh/uv/concepts/projects/sync/", + }, + ), +} + +# House rules (BSK codes) carry no conformance-category prefix; each maps to +# the chapter/PEPs documenting the mechanism it polices. The suppression rules +# (BSK-0060..0063) police Basilisk's own directives — no upstream doc exists — +# and BSK-0025's doc comment already cites PEP 698 directly. +REFERENCE_PREFIX_BY_BSK_CODE = { + "BSK-0001": "annotations", + "BSK-0002": "annotations", + "BSK-0003": "annotations", + "BSK-0004": "annotations", + "BSK-0005": "annotations", + "BSK-0011": "packaging", + "BSK-0012": "packaging", + "BSK-0013": "uv", + "BSK-0014": "specialtypes", + "BSK-0040": "annotations", + "BSK-0050": "annotations", + "BSK-0152": "imports", +} HEADER = re.compile(r"//!\s*(BSK-\d{4}|`[a-z0-9_]+`):\s*(.*)") DOC = re.compile(r"//!\s?(.*)") +PEP_MENTION = re.compile(r"\bPEP (\d{1,4})\b") DOCS_URL = re.compile(r'docs_url:\s*"([^"]+)"') SPEC_REF = re.compile(r"^Implements ") # A rule is Basilisk-original (off by default, opt-in only) iff it overrides @@ -125,14 +232,49 @@ def group_for(code: str, free_form_tags: list[str]) -> str: return "Type System" +def pep_url(number: int) -> str: + return f"https://peps.python.org/pep-{number:04d}/" + + +def link_peps(text: str) -> str: + """Turn every `PEP NNN` mention into a link to its canonical page.""" + return PEP_MENTION.sub( + lambda m: f'PEP {int(m.group(1))}', + text, + ) + + def inline_html(text: str) -> str: """Render a rustdoc line as safe inline HTML: intra-doc links unwrapped, - `code` spans and *emphasis* preserved.""" + `code` spans and *emphasis* preserved, PEP mentions linked + ([WEBSITE-ERROR-PAGES-REFERENCES]).""" text = re.sub(r"\[`?([^`\]]+)`?\]", r"\1", text) # [`Foo`] / [BSK-X] -> Foo text = html.escape(text) text = re.sub(r"`([^`]+)`", r"\1", text) text = re.sub(r"(?\1", text) - return text + return link_peps(text) + + +# Implements [WEBSITE-ERROR-PAGES-REFERENCES]: the canonical-documentation list +# for one code — its typing-spec chapter, then the chapter's PEPs merged with +# every PEP the rule's own doc comment cites, then any language-reference link. +def references_for(code: str, doc_text: str) -> list[dict]: + prefix = REFERENCE_PREFIX_BY_BSK_CODE.get(code, code.partition("_")[0]) + refs: list[dict] = [] + chapter = SPEC_CHAPTER_BY_PREFIX.get(prefix) + if chapter: + title, page = chapter + refs.append( + { + "label": f"Typing spec: {title}", + "url": f"{TYPING_SPEC_BASE_URL}/{page}", + } + ) + mentioned = {int(n) for n in PEP_MENTION.findall(doc_text)} + for number in sorted(mentioned.union(PEPS_BY_PREFIX.get(prefix, ()))): + refs.append({"label": f"PEP {number}", "url": pep_url(number)}) + refs.extend(LANGUAGE_REFS_BY_PREFIX.get(prefix, ())) + return refs ENDS_SENTENCE = (".", "!", ")", ":") @@ -240,6 +382,7 @@ def extract() -> list[dict]: "docsUrl": file_docs_url.group(1) if file_docs_url else f"{ERRORS_BASE_URL}/{code}", + "references": references_for(code, " ".join([summary, *body_lines])), } return [records[c] for c in sorted(records, key=sort_key)] diff --git a/scripts/setup.sh b/scripts/setup.sh index da98ad59..5f20164a 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -48,6 +48,32 @@ header "Installing Python packages" python3 -c 'import debugpy' &>/dev/null || python3 -m pip install --quiet --break-system-packages debugpy ok "debugpy" +# ruff is a hard requirement of `make test` (scripts/audit.sh fails without it) +# and of `make fmt`. Pin it to the version CI installs, which is also the +# ruff_* crate tag in Cargo.toml — a mismatched CLI formats differently from the +# formatter embedded in the binary ([LSPFMT-ENGINE]). +command -v ruff &>/dev/null || python3 -m pip install --quiet --break-system-packages ruff==0.15.17 +ok "ruff" + +# pytest drives the Neovim e2e harness; scripts/test-nvim.sh aborts without it. +command -v pytest &>/dev/null || python3 -m pip install --quiet --break-system-packages pytest==9.1.1 +ok "pytest" + +header "Installing Neovim (basilisk.nvim e2e tests)" + +# `make test` runs the basilisk.nvim suite and scripts/audit.sh gates on Neovim +# 0.11+ — the floor basilisk.nvim/lua/basilisk/health.lua enforces. Ask Neovim +# itself, with the same has() predicate health.lua uses, so an under-version +# install is upgraded instead of silently accepted. +if nvim --clean --headless -c "lua io.write(vim.fn.has('nvim-0.11'))" -c quit 2>/dev/null | grep -q 1; then + ok "neovim" +elif command -v brew &>/dev/null; then + brew install neovim || brew upgrade neovim + ok "neovim" +else + fail "Neovim 0.11+ not found and Homebrew is unavailable — install it: https://neovim.io" +fi + header "Installing deslop (duplication gate)" bash "$REPO_ROOT/scripts/install-deslop.sh" diff --git a/scripts/test-nvim.sh b/scripts/test-nvim.sh index c21713eb..b1423cf1 100755 --- a/scripts/test-nvim.sh +++ b/scripts/test-nvim.sh @@ -43,7 +43,17 @@ if ! command -v pytest &>/dev/null; then fi ok "pytest: $(pytest --version 2>&1 | head -1)" -header "Neovim extension — real LSP e2e tests" +# The tests/dap specs drive the real debug adapter, which launches debugpy. +# Without it the LSP answers every startDebugSession with "debugpy not found" +# and ~20 specs fail on assertions that look unrelated to the missing package. +# Fail here instead, with the fix in the message. +if ! python3 -c "import debugpy" &>/dev/null; then + echo -e "${RED}${BOLD}FATAL: debugpy not found — the DAP specs cannot run.${RESET}" + echo -e "${RED}Install it: python3 -m pip install debugpy==1.8.14${RESET}" + exit 1 +fi +ok "debugpy: $(python3 -c 'import debugpy; print(debugpy.__version__)' 2>&1)" + cd "$REPO_ROOT/basilisk.nvim" # Ensure plenary.nvim is available. @@ -61,10 +71,46 @@ fi # ── Tests ───────────────────────────────────────────────────────────────────── +# Run one plenary spec directory and gate on PARSED results, exactly as the LSP +# suite below does — every spec file must run AND summarise with zero +# failures/errors/tracebacks. See common.sh +# [LSPTEST-EDITOR-SPECIFIC-INTEGRATION-NEOVIM-E2E-GATE]. +run_plenary_dir() { + local dir="$1" label="$2" expected out + expected="$(find "$dir" -name '*_spec.lua' | wc -l | tr -d ' ')" + out="$(mktemp)" + # No LUACOV here on purpose: the LSP suite below deletes luacov.stats.out + # before a retry, so stats gathered by an earlier suite would silently + # vanish on the retry path and make the coverage threshold non-deterministic. + # The LSP e2e run remains the single, reproducible coverage input. + set +e + nvim --headless -u tests/minimal_init.lua \ + -c "PlenaryBustedDirectory ${dir} {minimal_init = 'tests/minimal_init.lua', sequential = true, timeout = 300000}" 2>&1 \ + | tee "$out" + set -e + if ! assert_plenary_pass "$out" "$expected" "$label"; then + rm -f "$out" + exit 1 + fi + rm -f "$out" + ok "$label passed" +} + if command -v nvim &>/dev/null; then # Remove stale luacov data so coverage reflects this run only. rm -f luacov.stats.out luacov.report.out + # The unit and DAP specs run BEFORE the LSP e2e suite: they need no binary + # round-trip, so a broken module surfaces in seconds instead of after the + # multi-minute e2e pass. They are gated identically — these 15 spec files + # were previously executed by nothing at all. + header "Neovim extension — unit specs" + run_plenary_dir tests/basilisk "Neovim unit tests" + header "Neovim extension — DAP specs" + run_plenary_dir tests/dap "Neovim DAP tests" + + header "Neovim extension — real LSP e2e tests" + # Plenary spawns a child nvim per test file. With coverage enabled, # children must run sequentially so luacov stats files merge correctly # instead of racing on concurrent writes. diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 3ed9eb41..e5a704af 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -1,31 +1,40 @@ +

- Basilisk + Basilisk

-

Basilisk for VS Code

+

Basilisk

English · 简体中文

The only Python type checker scoring 100% on the official python/typing conformance suite — and the fastest we’ve measured.
Complete open-source Python dev environment in Rust: type checker, language server, debugger, profiler, plus VS Code, Cursor, Zed & Neovim extensions. Strict by default. + Weighing up the best Python type checker for your codebase? Start with the scoreboard.

+> **You are reading the Basilisk extension listing** for VS Code, Cursor, Windsurf, and every VS Code fork — the same extension is published to the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) and [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk). +

Website  •  + Install  •  Quick Start  •  Rules  •  - Conformance  •  + Refactoring  •  + Compare  •  GitHub

- 100.0% PEP conformance141 of 141 tests in the official - python/typing - conformance suite, scored on the wheel-installed CLI in its default config by the real upstream harness. The only checker on the board at 100%. + 100.0% PEP conformance141 of 141 tests in the official + python/typing + conformance suite (commit 6ef9f77), scored on the wheel-installed CLI in its default config by the real upstream harness. + We target python/typing@main and ratchet the score up only.

-## The only 100% checker — and the fastest according to our benchmarks +## The only 100% checker — and the fastest according to our benchmarks Basilisk is the **only** Python type checker with a perfect score on the official [`python/typing` conformance suite](https://github.com/python/typing/blob/main/conformance/results/results.html): @@ -33,7 +42,7 @@ Basilisk is the **only** Python type checker with a perfect score on the officia measured by the real upstream harness on the wheel-installed CLI in its default config.

- Basilisk in action — type checking, diagnostics, and refactoring in VS Code + Basilisk in action — type checking, diagnostics, and refactoring in the editor

And it is the **fastest checker we’ve measured** — median cold full-file check, from scratch: @@ -47,7 +56,7 @@ And it is the **fastest checker we’ve measured** — median cold full- | Pyright | 573 ms | | mypy | 574 ms | -Median cold full-file check across 26 single-construct typing-spec stress fixtures on an Apple M4 Max — lower is better; inside the editor a warm re-check is faster again. Every figure is produced by [`hyperfine`](https://github.com/sharkdp/hyperfine) and committed per machine, so nothing here is hand-typed. **Clone the repo, run `make bench` on your own hardware, and send us the CSV — independent audits are welcome.** [Full benchmarks & methodology →](https://www.basilisk-python.dev/docs/benchmarks/) +Median cold full-file check across 26 single-construct typing-spec stress fixtures on an Apple M4 Max — lower is better. Basilisk’s warm re-check drops to ~4 ms. Every figure is produced by [`hyperfine`](https://github.com/sharkdp/hyperfine) and committed per machine, so nothing here is hand-typed. **Clone the repo, run `make bench` on your own hardware, and send us the CSV — independent audits are welcome.** [Full benchmarks & methodology →](https://www.basilisk-python.dev/docs/benchmarks/) ## Everything in one extension @@ -60,23 +69,107 @@ One extension replaces Pylance and gives you the whole workflow — no Node.js, - **Integrated profiling** — CPU heat map, flame graph, and a memory dashboard with leak detection - **Activity panel** — module tree with per-module type-health coverage, plus feature toggles - **Inlay hints** and **Ruff** formatting/import-organization, built in -- **Standard-library types from [typeshed](https://github.com/python/typeshed)** — verified `python/typeshed@main` by default, with a complete `stdlib/` snapshot compiled into the bundled binary so hover and diagnostics keep working offline +- **Standard-library types from [typeshed](https://github.com/python/typeshed)** — a complete `stdlib/` snapshot is compiled into the binary, so hover and diagnostics work offline with no configuration Every diagnostic teaches: rustc-style output with a `help`, a `note`, and a link to a per-rule explainer, so a red squiggle always tells you *why*. Basilisk **starts strict** and stays strict — the unconfigured default enables the complete typing-spec rule set, and strictness is dialled per rule, never by a mode. -## Zero install +## Install + +**Editor extension** — install *Basilisk* from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) or [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk) (Cursor, Windsurf, and other forks read Open VSX). The Basilisk binary is bundled for macOS (Apple Silicon), Linux (x86_64, aarch64), and Windows (x86_64, aarch64) — nothing else to install. Zed and Neovim 0.10+ extensions are available too. + +**CLI** — on [PyPI as `basilisk-python`](https://pypi.org/project/basilisk-python/); the installed command is `basilisk`: + +```sh +uv tool install basilisk-python # or: pipx install basilisk-python, pip install basilisk-python +``` + +Also via Homebrew (`brew install Nimblesite/tap/basilisk`), Scoop (`scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket && scoop install basilisk`), and [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases). Every channel ships the same single Rust CLI, built from this repository at the same version, with no runtime dependencies. Point `basilisk.executablePath` at your own build to have the extension use it. Full options: [install guide](https://www.basilisk-python.dev/docs/installation/). + +## Try it + +The [`examples/`](https://github.com/Nimblesite/Basilisk/blob/main/examples/) folder has ready-to-go Python files: + +```sh +basilisk check examples/bad.py # 8 typing-spec errors — always on, no config needed +basilisk analyze examples/bad.py # the opt-in strictness warnings on the same file +basilisk analyze examples/good.py # clean, even at full strictness +basilisk check examples/mixed.py # one real type error +basilisk check examples/ # the whole folder at once +``` + +Machine-readable output for CI and tooling: + +```sh +basilisk check path/to/your_code.py --output json --color never +``` -The Basilisk binary is bundled with this extension for macOS (Apple Silicon), Linux (x86_64, aarch64), and Windows (x86_64, aarch64). Install the extension and go. +The two commands read one rule universe split by provenance ([`CHKARCH-COMMANDS`](https://github.com/Nimblesite/Basilisk/blob/main/docs/specs/CHECKER-ARCHITECTURE-SPEC.md)): `check` reports +the `pep`-tagged typing-spec rules and nothing else — that set is always on, and +while a config table may grade one of them down to `warning`/`info`, none may +switch it off. `analyze` reports the non-`pep` house rules, which stay silent +until a table selects them. Only `analyze` emits `BSK-` diagnostics. -Want the `basilisk` CLI on your PATH too (for CI or the terminal)? `brew install Nimblesite/tap/basilisk`, `scoop install basilisk` (after `scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket`), or grab a binary from [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases). Point `basilisk.executablePath` at it to use your own build. +## Standard-library types, always offline + +Basilisk resolves the standard library from [typeshed](https://github.com/python/typeshed), +and checking **never downloads anything**. Out of the box it uses the complete +typeshed `stdlib/` snapshot compiled into the binary, reporting the source as +unpinned — so stdlib types work on a plane, behind a firewall, or in an +air-gapped CI runner, with no configuration. + +Pin an exact commit with `typeshed-commit = "<40-char sha>"` under +`[tool.basilisk]`. A pin does exactly one thing: it verifies, offline, that the +typeshed tree in the local store hashes to that commit. If the commit is not on +this machine the run fails hard with `NO SOURCE` rather than substituting +another source — bring it down first with `basilisk typeshed download` (with no +`--commit` it downloads the latest and writes the pin for you), or use the +editor's **Download latest** button. Alternatively, point `typeshed-path` at +your own typeshed tree. Full options: +[configuration guide](https://www.basilisk-python.dev/docs/configuration/). + +## Development + +```sh +cargo build # build all crates +cargo test # run all tests +cargo clippy # lint (zero warnings policy) +cargo fmt # format +``` + +Rust 1.87+ required. + +## Contributing + +Basilisk is built by a human + AI partnership, with the work split on purpose. See +[CONTRIBUTING.md](https://github.com/Nimblesite/Basilisk/blob/main/CONTRIBUTING.md) — **For Humans** (testing, code-quality review, +conformance/security audits, IDE feature parity, sharpening the AI instructions) and +**For AI** (the technical execution, under the standing rules in [CLAUDE.md](https://github.com/Nimblesite/Basilisk/blob/main/CLAUDE.md)). ## Acknowledgments -Built on [Ruff](https://github.com/astral-sh/ruff) by [Astral](https://astral.sh/) (MIT) and [typeshed](https://github.com/python/typeshed) (Apache-2.0, with MIT-licensed parts); bundles [debugpy](https://github.com/microsoft/debugpy) (Microsoft, MIT). The VSIX ships Rust notices in `RUST-DEPENDENCY-LICENSES`, npm notices in `VSCODE-DEPENDENCY-LICENSES`, and debugpy's own license and `ThirdPartyNotices.txt` inside `bundled/debugpy`. +Basilisk builds on the open-source community — with thanks to: + +- **[Astral](https://astral.sh/)** — [Ruff](https://github.com/astral-sh/ruff), whose parser, AST, and formatter crates Basilisk embeds (MIT). The foundation we rely on most. +- **[typeshed](https://github.com/python/typeshed)** — standard-library type stubs (Apache-2.0, with MIT-licensed parts). +- **[Salsa](https://github.com/salsa-rs/salsa)** — incremental query engine. +- **[Rayon](https://github.com/rayon-rs/rayon)** — data parallelism. +- **[tower-lsp](https://github.com/ebkalderon/tower-lsp)** — LSP scaffolding. +- **[debugpy](https://github.com/microsoft/debugpy)** — debug adapter (bundled in the VS Code extension). +- The [`python/typing`](https://github.com/python/typing) conformance suite. + +Full component list, selected licenses, and required notices: [NOTICES](https://github.com/Nimblesite/Basilisk/blob/main/NOTICES) +and [RUST-DEPENDENCY-LICENSES](https://github.com/Nimblesite/Basilisk/blob/main/RUST-DEPENDENCY-LICENSES). Each published +artifact carries its own copies: the VSIX ships Rust notices in +`RUST-DEPENDENCY-LICENSES`, npm notices in `VSCODE-DEPENDENCY-LICENSES`, and +debugpy's license and `ThirdPartyNotices.txt` inside `bundled/debugpy`; the +wheel carries the complete locked notices in its `.dist-info/licenses/` +directory. + +--- ## License -Basilisk source code is MIT licensed. The VSIX also contains third-party -components under the license files and notices packaged with the extension. +Basilisk source code is MIT licensed. Binary distributions also contain +third-party components under the licenses shipped beside each artifact. Built by [NIMBLESITE PTY LTD](https://www.nimblesite.co). diff --git a/vscode-extension/README.zh.md b/vscode-extension/README.zh.md index 6395b8c0..a1d99784 100644 --- a/vscode-extension/README.zh.md +++ b/vscode-extension/README.zh.md @@ -1,45 +1,54 @@ +

- Basilisk + Basilisk

-

Basilisk for VS Code

+

Basilisk

English · 简体中文

-> 📝 本文档由机器翻译生成,欢迎母语者校对改进。 -

- 唯一在官方 python/typing 符合性套件中取得 100% 满分的 Python 类型检查器 —— 也是我们测过的最快的。
- 使用 Rust 构建的完整开源 Python 开发环境:类型检查器、语言服务器、调试器与性能分析器,并提供 VS Code、Cursor、Zed 与 Neovim 扩展。默认严格。 + 唯一在官方 python/typing 一致性测试套件上取得 100% 的 Python 类型检查器 —— 也是我们测得最快的。
+ 用 Rust 打造的完整开源 Python 开发环境:类型检查器、语言服务器、调试器、性能分析器,以及 VS Code、Cursor、Zed 与 Neovim 扩展。默认严格。

+> **你正在阅读 Basilisk 的扩展页面**,适用于 VS Code、Cursor、Windsurf 以及所有 VS Code 分支 —— 同一个扩展同时发布到 [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) 与 [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk)。 +

- 官网  •  - 快速开始  •  + 网站  •  + 安装  •  + 快速上手  •  规则  •  - 一致性  •  + 重构  •  + 对比  •  GitHub

- PEP 符合性 100.0% —— 官方 - python/typing - 一致性测试套件 141 个测试中通过 141 个,由真实的上游评分工具在默认配置下对通过 wheel 安装的 CLI 评分。该榜单上唯一取得满分的检查器。 + PEP 一致性 100.0% — 官方 + python/typing + 一致性套件(提交 6ef9f77141 项测试中通过 141 项, + 由真实的上游评分器在默认配置下对 wheel 安装的 CLI 评出。 + 我们以 python/typing@main 为目标,且分数只升不降。

-## 唯一满分 —— 且最快 +## 唯一 100% 的检查器 — 按我们的基准测试也是最快的 Basilisk 是**唯一**在官方 -[`python/typing` 符合性套件](https://github.com/python/typing/blob/main/conformance/results/results.html) -上取得满分的 Python 类型检查器:**100.0%**(141/141 个文件,捕获 970 个必需错误,0 个误报),由真实的上游评分工具在默认配置下对通过 wheel 安装的 CLI 评分。 +[`python/typing` 一致性套件](https://github.com/python/typing/blob/main/conformance/results/results.html) +上取得满分的 Python 类型检查器:**100.0%** +(141/141 个文件,捕获 970 处必需错误,0 个误报), +由真实的上游评分器在默认配置下对 wheel 安装的 CLI 测得。

- Basilisk 实战 —— 在 VS Code 中进行类型检查、诊断与重构 + Basilisk 实战 —— 编辑器中的类型检查、诊断与重构

-它也是**我们测试过最快的检查器** —— 从零冷启动的全文件检查中位数: +它也是**我们测得最快的检查器** — 从零开始的整文件冷检查中位数: -| 类型检查器 | 冷启动检查中位数 | +| 类型检查器 | 冷检查中位数 | | --- | --- | | ⚡ **Basilisk** | **10 ms** | | zuban | 28 ms | @@ -48,36 +57,114 @@ Basilisk 是**唯一**在官方 | Pyright | 573 ms | | mypy | 574 ms | -在 Apple M4 Max 上,跨 26 个单一类型构造压力测试样本的冷启动全文件检查中位数 —— 数值越低越好;在编辑器内热重检查更快。每个数字均由 [`hyperfine`](https://github.com/sharkdp/hyperfine) 生成并按机器提交,没有一个是手写的。**克隆仓库,在你自己的硬件上运行 `make bench`,并把 CSV 发给我们 —— 欢迎社区独立复核。** [完整基准测试与方法论(英文)→](https://www.basilisk-python.dev/docs/benchmarks/) +在 Apple M4 Max 上对 26 个单一构造的类型规范压力用例测得的整文件冷检查中位数 — 越低越好。Basilisk 的热重检查可降至约 4 ms。每个数字都由 [`hyperfine`](https://github.com/sharkdp/hyperfine) 产生并按机器提交,没有一个是手写的。**克隆仓库,在你自己的硬件上运行 `make bench`,并把 CSV 发给我们 — 欢迎独立复核。** [完整基准与方法论 →](https://www.basilisk-python.dev/zh/docs/benchmarks/) -## 一个扩展,全部搞定 +## 一个扩展,覆盖全部 -一个扩展替代 Pylance,覆盖完整工作流 —— 无需 Node.js、Python 运行时、pip 或 npm。由单一内置 Rust 二进制文件驱动一切: +一个扩展即可取代 Pylance 并提供完整工作流 —— 无需 Node.js、无需 Python 运行时、无需 pip、无需 npm。一切由单一捆绑的 Rust 二进制文件驱动: -- **默认严格的诊断** —— 边输入边内联显示,基于 Salsa 的增量分析(与 rust-analyzer 同源) +- **默认严格的诊断** —— 随输入实时呈现,由 Salsa(rust-analyzer 的引擎)提供增量分析 - **自动补全、悬停信息、跳转到定义、查找引用、重命名** - **重构代码操作** —— 提取、内联、移动符号、整理导入 -- **集成调试** —— 按 F5 通过内置 debugpy 调试;无需额外扩展 -- **集成性能分析** —— CPU 热力图、火焰图,以及带泄漏检测的内存仪表盘 -- **活动面板** —— 带每模块类型健康覆盖率的模块树,以及功能开关 -- **内嵌提示**与内置的 **Ruff** 格式化 / 导入整理 -- **来自 [typeshed](https://github.com/python/typeshed) 的标准库类型** —— 默认校验 `python/typeshed@main`,并在捆绑的二进制文件中编译进完整的 `stdlib/` 快照,因此悬停信息与诊断在离线时同样可用 +- **集成调试** —— 按 F5 即可通过捆绑的 debugpy 调试;无需额外扩展 +- **集成性能分析** —— CPU 热力图、火焰图,以及带泄漏检测的内存面板 +- **活动面板** —— 模块树与逐模块的类型健康度覆盖率,并可切换功能开关 +- 内置 **Inlay hints** 与 **Ruff** 格式化/导入整理 +- **来自 [typeshed](https://github.com/python/typeshed) 的标准库类型** —— 完整的 `stdlib/` 快照已编译进二进制文件,因此悬停与诊断在离线且零配置的情况下依然可用 + +每条诊断都有教育意义:rustc 风格的输出,附带 `help`、`note` 以及指向每条规则详解页的链接,因此一条红色波浪线总能告诉你*为什么*。Basilisk **一开始就严格**并始终严格 —— 未配置的默认值即启用完整的类型规范规则集,严格程度按规则微调,而不是靠模式切换。 + +## 安装 + +**编辑器扩展** —— 从 [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk) 或 [Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk) 安装 *Basilisk*(Cursor、Windsurf 等分支读取 Open VSX)。Basilisk 二进制文件已为 macOS(Apple Silicon)、Linux(x86_64、aarch64)与 Windows(x86_64、aarch64)捆绑 —— 无需再安装其他东西。Zed 与 Neovim 0.10+ 的扩展同样可用。 + +**命令行工具** —— 在 [PyPI 上名为 `basilisk-python`](https://pypi.org/project/basilisk-python/);安装后的命令是 `basilisk`: + +```sh +uv tool install basilisk-python # 或:pipx install basilisk-python、pip install basilisk-python +``` + +也可通过 Homebrew(`brew install Nimblesite/tap/basilisk`)、Scoop(`scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket && scoop install basilisk`)与 [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases) 获取。每个渠道都发布同一个 Rust 命令行工具,由本仓库在同一版本构建,且没有运行时依赖。把 `basilisk.executablePath` 指向你自己的构建,扩展就会使用它。完整选项:[安装指南](https://www.basilisk-python.dev/zh/docs/installation/)。 + +## 试一试 + +[`examples/`](https://github.com/Nimblesite/Basilisk/blob/main/examples/) 目录中有可直接运行的 Python 文件: + +```sh +basilisk check examples/bad.py # 8 处类型规范错误 —— 始终启用,无需配置 +basilisk analyze examples/bad.py # 同一文件上可选的严格性警告 +basilisk analyze examples/good.py # 即使在完全严格下也是干净的 +basilisk check examples/mixed.py # 一处真实的类型错误 +basilisk check examples/ # 一次检查整个目录 +``` -每条诊断都有教育意义:rustc 风格的输出,附带 `help`、`note` 以及指向每条规则详解页的链接,因此一条红色波浪线总能告诉你*原因*。Basilisk **默认严格**并始终保持严格 —— 未经配置的默认设置即启用完整的类型规范规则集,严格程度按规则调整,而非通过模式切换。 +供 CI 与工具使用的机器可读输出: -## 零安装 +```sh +basilisk check path/to/your_code.py --output json --color never +``` -Basilisk 二进制文件已随本扩展捆绑,覆盖 macOS(Apple Silicon)、Linux(x86_64、aarch64)与 Windows(x86_64、aarch64)。安装扩展即可使用。 +这两条命令读取的是按来源划分的同一套规则宇宙([`CHKARCH-COMMANDS`](https://github.com/Nimblesite/Basilisk/blob/main/docs/specs/CHECKER-ARCHITECTURE-SPEC.md)):`check` +只报告带 `pep` 标签的类型规范规则 —— 该集合始终启用,配置表虽可将其中某条 +降级为 `warning`/`info`,但都不能将其关闭。`analyze` 报告非 `pep` 的自有规则, +它们在被配置表选用之前始终保持沉默。只有 `analyze` 会输出 `BSK-` 诊断。 -也想把 `basilisk` CLI 放到 PATH 上(用于 CI 或终端)?`brew install Nimblesite/tap/basilisk`、`scoop install basilisk`(先执行 `scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket`),或从 [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases) 下载二进制文件。将 `basilisk.executablePath` 指向它即可使用你自己的构建。 +## 标准库类型:始终离线 + +Basilisk 从 [typeshed](https://github.com/python/typeshed) 解析标准库类型, +而且检查**从不下载任何东西**。开箱即用时它使用编译进二进制文件的完整 typeshed +`stdlib/` 快照,并将来源报告为未固定(unpinned)—— 因此在飞机上、防火墙后或 +隔离网络的 CI 中,标准库类型都无需配置即可使用。 + +在 `[tool.basilisk]` 中使用 `typeshed-commit = "<40 位 sha>"` 固定到某个确切提交。 +固定只做一件事:离线校验本地存储库中的 typeshed 树是否哈希为该提交。若该提交 +不在本机上,运行会以 `NO SOURCE` 硬失败,而不会替换为其他来源 —— 请先用 +`basilisk typeshed download` 取回(不带 `--commit` 时会下载最新提交并替你写入 +固定项),或使用编辑器中的 **Download latest** 按钮。或者,把 `typeshed-path` +指向你自己的 typeshed 目录树。完整选项参见[配置指南](https://www.basilisk-python.dev/zh/docs/configuration/)。 + +## 开发 + +```sh +cargo build # build all crates +cargo test # run all tests +cargo clippy # lint (zero warnings policy) +cargo fmt # format +``` + +需要 Rust 1.87+。 + +## 贡献 + +Basilisk 由人类与 AI 的协作打造,并有意地划分了各自的工作。请参阅 +[CONTRIBUTING.md](https://github.com/Nimblesite/Basilisk/blob/main/CONTRIBUTING.md) —— **For Humans**(测试、代码质量审查、 +一致性/安全审计、IDE 功能对等、打磨 AI 指令)以及 +**For AI**(在 [CLAUDE.md](https://github.com/Nimblesite/Basilisk/blob/main/CLAUDE.md) 既定规则下的技术执行)。 ## 致谢 -基于 [Astral](https://astral.sh/) 的 [Ruff](https://github.com/astral-sh/ruff)(MIT)与 [typeshed](https://github.com/python/typeshed)(Apache-2.0,部分内容采用 MIT 许可证)构建;捆绑了 [debugpy](https://github.com/microsoft/debugpy)(Microsoft,MIT)。VSIX 在 `RUST-DEPENDENCY-LICENSES` 中提供 Rust 声明,在 `VSCODE-DEPENDENCY-LICENSES` 中提供 npm 声明,并在 `bundled/debugpy` 内保留 debugpy 自身的许可证与 `ThirdPartyNotices.txt`。 +Basilisk 建立在开源社区之上 —— 特别感谢: + +- **[Astral](https://astral.sh/)** —— [Ruff](https://github.com/astral-sh/ruff),Basilisk 嵌入了其解析器、AST 与格式化器 crate(MIT)。我们最倚重的基础。 +- **[typeshed](https://github.com/python/typeshed)** —— 标准库类型存根(Apache-2.0,部分内容采用 MIT 许可证)。 +- **[Salsa](https://github.com/salsa-rs/salsa)** —— 增量查询引擎。 +- **[Rayon](https://github.com/rayon-rs/rayon)** —— 数据并行。 +- **[tower-lsp](https://github.com/ebkalderon/tower-lsp)** —— LSP 脚手架。 +- **[debugpy](https://github.com/microsoft/debugpy)** —— 调试适配器(捆绑于 VS Code 扩展)。 +- [`python/typing`](https://github.com/python/typing) 一致性测试套件。 + +完整的组件、所选许可证与必要声明见 [NOTICES](https://github.com/Nimblesite/Basilisk/blob/main/NOTICES) 和 +[RUST-DEPENDENCY-LICENSES](https://github.com/Nimblesite/Basilisk/blob/main/RUST-DEPENDENCY-LICENSES)。每个发布的产物也各自 +携带副本:VSIX 在 `RUST-DEPENDENCY-LICENSES` 中提供 Rust 声明,在 +`VSCODE-DEPENDENCY-LICENSES` 中提供 npm 声明,并在 `bundled/debugpy` 内保留 +debugpy 自身的许可证与 `ThirdPartyNotices.txt`;wheel 则在 `.dist-info/licenses/` +目录中携带完整的锁定声明。 + +--- ## 许可证 -Basilisk 源代码采用 MIT 许可证。VSIX 还包含第三方组件;其许可证与声明均随 -扩展一并提供。 +Basilisk 源代码采用 MIT 许可证。二进制发行物还包含第三方组件;其许可证 +随每个发行物一并提供。 由 [NIMBLESITE PTY LTD](https://www.nimblesite.co) 构建。 diff --git a/vscode-extension/VSCODE-DEPENDENCY-LICENSES b/vscode-extension/VSCODE-DEPENDENCY-LICENSES index 225bb1fe..8bc1432e 100644 --- a/vscode-extension/VSCODE-DEPENDENCY-LICENSES +++ b/vscode-extension/VSCODE-DEPENDENCY-LICENSES @@ -2,7 +2,7 @@ Basilisk VS Code Production Dependency Licenses ================================================= Generated from the exact npm production graph selected by package-lock.json. -Production graph SHA-256: 0cde329d95c6111d14152a9e04f4bd890377383c0fa5994f216c19b3834a0767 +Production graph SHA-256: 43f5f5fd0daae31342024112becb94923452b92c929a2820b1eb50eca7f9f186 Regenerate with: npm run licenses:update =============================================================================== @@ -124,9 +124,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. =============================================================================== -brace-expansion 5.0.6 +brace-expansion 5.0.7 License: MIT -Repository: git+ssh://git@github.com/juliangruber/brace-expansion.git +Repository: git+https://github.com/juliangruber/brace-expansion.git Source: LICENSE SHA-256: 9c63a23124d68cd30cd316a94a1a0bca34f032786df6df69fc4b5f136bac8d2e diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index 6a005805..9fb0203d 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1701,9 +1701,9 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4188,9 +4188,9 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/vscode-extension/src/configuration-editor-document.ts b/vscode-extension/src/configuration-editor-document.ts index 0fbf36e1..f25fe4ba 100644 --- a/vscode-extension/src/configuration-editor-document.ts +++ b/vscode-extension/src/configuration-editor-document.ts @@ -73,16 +73,23 @@ const RULES_SECTION = ` `; -const OVERLAYS = ` +// The impact dialog for rule/tag entries is the ONLY modal surface. Editor +// lifecycle (loading, failure, conflict) renders as a non-blocking inline +// notice row — there is no full-panel overlay, no spinner view, and no lock +// screen anywhere in this document ([LSPCFGED-TYPESHED-DOWNLOAD]). +const DIALOGS = `

Review exact impact

Exact resolved changes

-
`; +const STATE_NOTICE = ` + `; + const BODY = `

Basilisk Configuration

Waiting for workspace…
No active source
Connecting…
+ ${STATE_NOTICE}
${SECTION_NAV}
${OVERVIEW_SECTION}${RULES_SECTION}${ADOPTION_SECTION}${PATHS_SECTION}${PROJECT_SECTION}
- ${OVERLAYS}`; + ${DIALOGS}`; /** Build the complete CSP-locked document; no workspace data is interpolated. */ export function buildConfigurationEditorDocument(): string { diff --git a/vscode-extension/src/configuration-editor-intents.ts b/vscode-extension/src/configuration-editor-intents.ts index 34243b1e..7809574a 100644 --- a/vscode-extension/src/configuration-editor-intents.ts +++ b/vscode-extension/src/configuration-editor-intents.ts @@ -8,7 +8,6 @@ import type { RuleSeverity, TypeshedAction, TypeshedSettingKey, - TypeshedSettingValue, } from "./configuration-editor-model"; const MAX_MUTATIONS = 512; @@ -30,7 +29,7 @@ export type ConfigurationEditorIntent = | { readonly type: "occurrences"; readonly request: Omit } | { readonly type: "openDocs"; readonly uri: string } | { readonly type: "openOccurrence"; readonly uri: string; readonly line: number; readonly character: number } - | { readonly type: "pickTypeshedFolder"; readonly key: "TypeshedPath" | "TypeshedCachePath" } + | { readonly type: "pickTypeshedFolder"; readonly key: "TypeshedPath" | "TypeshedStorePath" } | { readonly type: "typeshedAction"; readonly action: TypeshedAction }; function isRecord(value: unknown): value is Record { @@ -84,39 +83,21 @@ function decodeTypeshedKey(value: unknown): TypeshedSettingKey | undefined { switch (value.kind) { case "TypeshedPath": return { kind: "TypeshedPath" }; case "TypeshedCommit": return { kind: "TypeshedCommit" }; - case "TypeshedUrl": return { kind: "TypeshedUrl" }; - case "TypeshedCachePath": return { kind: "TypeshedCachePath" }; - case "TypeshedCache": return { kind: "TypeshedCache" }; - case "TypeshedVerify": return { kind: "TypeshedVerify" }; + case "TypeshedStorePath": return { kind: "TypeshedStorePath" }; default: return undefined; } } -function decodeTypeshedValue(value: unknown): TypeshedSettingValue | undefined { - if (!isRecord(value) || typeof value.kind !== "string") { return undefined; } - if (value.kind === "Text") { - const text = boundedString(value.value); - return text === undefined ? undefined : { kind: "Text", value: text }; - } - return value.kind === "Boolean" && typeof value.value === "boolean" - ? { kind: "Boolean", value: value.value } - : undefined; -} - -function typeshedValueMatches(key: TypeshedSettingKey, value: TypeshedSettingValue): boolean { - const booleanKey = key.kind === "TypeshedCache" || key.kind === "TypeshedVerify"; - return booleanKey ? value.kind === "Boolean" : value.kind === "Text"; -} - /** * The only four things the editor can request: set or remove one rule entry * or one tag entry ([CHKARCH-CONFIG-MODEL], [CONFIGEDITOR-OPERATIONS]). + * Every surviving Typeshed key is text-valued ([LSPCFGED-TYPESHED]). */ function decodeTypeshedMutation(value: Record): EditorMutation | undefined { if (value.kind === "SetTypeshedSetting") { const key = decodeTypeshedKey(value.key); - const setting = decodeTypeshedValue(value.value); - return key === undefined || setting === undefined || !typeshedValueMatches(key, setting) + const setting = boundedString(value.value); + return key === undefined || setting === undefined ? undefined : { kind: "SetTypeshedSetting", key, value: setting }; } @@ -161,8 +142,8 @@ function decodeMutation(value: unknown): EditorMutation | undefined { function decodeTypeshedAction(value: unknown): TypeshedAction | undefined { switch (value) { - case "PinCurrent": return { kind: "PinCurrent" }; - case "AcquireFresh": return { kind: "AcquireFresh" }; + case "DownloadLatest": return { kind: "DownloadLatest" }; + case "DownloadPinned": return { kind: "DownloadPinned" }; case "ViewLicense": return { kind: "ViewLicense" }; default: return undefined; } @@ -228,7 +209,7 @@ export function decodeConfigurationEditorIntent(value: unknown): ConfigurationEd return uri === undefined ? undefined : { type: "openConfigFile", uri }; } if (value.type === "pickTypeshedFolder") { - return value.key === "TypeshedPath" || value.key === "TypeshedCachePath" + return value.key === "TypeshedPath" || value.key === "TypeshedStorePath" ? { type: "pickTypeshedFolder", key: value.key } : undefined; } diff --git a/vscode-extension/src/configuration-editor-model.ts b/vscode-extension/src/configuration-editor-model.ts index b9633485..bd7b3400 100644 --- a/vscode-extension/src/configuration-editor-model.ts +++ b/vscode-extension/src/configuration-editor-model.ts @@ -42,76 +42,51 @@ export type TagKind = export type TypeshedSettingKey = | { kind: "TypeshedPath" } | { kind: "TypeshedCommit" } - | { kind: "TypeshedUrl" } - | { kind: "TypeshedCachePath" } - | { kind: "TypeshedCache" } - | { kind: "TypeshedVerify" }; - -export type TypeshedSettingValue = - | { kind: "Text"; value: string } - | { kind: "Boolean"; value: boolean }; + | { kind: "TypeshedStorePath" }; export type EditorMutation = | { kind: "SetRule"; code: RuleCode; severity: RuleSeverity } | { kind: "RemoveRule"; code: RuleCode } | { kind: "SetTag"; tag: RuleTag; severity: RuleSeverity } | { kind: "RemoveTag"; tag: RuleTag } - | { kind: "SetTypeshedSetting"; key: TypeshedSettingKey; value: TypeshedSettingValue } + | { kind: "SetTypeshedSetting"; key: TypeshedSettingKey; value: string } | { kind: "RemoveTypeshedSetting"; key: TypeshedSettingKey }; /** - * The active source carries the value that defines it: "latest with a pin" and - * "a pinned commit plus a custom folder" are unrepresentable ([LSPCFGED-TYPESHED]). + * There are exactly two sources, each carrying the value that defines it: + * "a pinned commit plus a custom folder" is unrepresentable, and there is no + * "track latest" source at all ([LSPCFGED-TYPESHED]). */ export type TypeshedSource = - | { kind: "Latest" } | { kind: "ExactCommit"; commit: string } | { kind: "CustomFolder"; path: string }; -/** Download policy of a downloaded source; a custom folder has none. */ -export interface TypeshedDownloadPolicy { - reuseDownloads: boolean; - verifyContent: boolean; - archiveUrl: string | undefined; - cacheFolder: string | undefined; -} - export type TypeshedLifecycle = - | { kind: "Acquiring" } + | { kind: "Downloading" } | { kind: "Ready" } - | { kind: "Blocked" }; + | { kind: "NoSource" }; export type TypeshedAction = - | { kind: "PinCurrent" } - | { kind: "AcquireFresh" } + | { kind: "DownloadLatest" } + | { kind: "DownloadPinned" } | { kind: "ViewLicense" }; +/** + * The active source is the whole trust story (custom = user-managed, bundled = + * build-vetted, exact commit = attested at download, re-proven offline), so + * there are no separate transport or provenance fields ([STUBRES-TYPESHED-WARN]). + */ export type TypeshedActiveSource = | { kind: "Custom" } | { kind: "ExactCommit" } - | { kind: "Latest" } | { kind: "Bundled" }; -export type TypeshedTransport = - | { kind: "CustomPath" } - | { kind: "EmbeddedZip" } - | { kind: "Codeload" } - | { kind: "Mirror" }; - export type TypeshedLicenseStatus = - | { kind: "Acquiring" } | { kind: "Unavailable" } | { kind: "Approved" } | { kind: "Changed" } | { kind: "NotSupplied" }; -export type TypeshedProvenance = - | { kind: "Pending" } - | { kind: "GithubTlsAttested" } - | { kind: "Unverified" } - | { kind: "BundleVetted" } - | { kind: "UserManaged" }; - export type TypeshedWarningSeverity = | { kind: "Advisory" } | { kind: "High" }; @@ -124,25 +99,21 @@ export interface TypeshedWarningState { export interface TypeshedStatusState { lifecycle: TypeshedLifecycle; - blockedReason: string | undefined; + noSourceReason: string | undefined; activeSource: TypeshedActiveSource | undefined; commitIdentity: string | undefined; - transport: TypeshedTransport | undefined; licenseStatus: TypeshedLicenseStatus; - provenance: TypeshedProvenance; - signedRelease: boolean; warnings: TypeshedWarningState[]; } /** * Everything the editor needs and nothing it can misrender: the one active - * source, the download policy that source has, the commit `PinCurrent` would - * write when pinning is possible, and whether a license document exists. + * source, the store folder pins resolve from (none for a custom folder), and + * whether a license document exists to open. */ export interface TypeshedConfigurationState { source: TypeshedSource; - downloads: TypeshedDownloadPolicy | undefined; - pinnableCommit: string | undefined; + storeFolder: string | undefined; licenseAvailable: boolean; status: TypeshedStatusState; } @@ -259,8 +230,8 @@ export interface ConfigurationPreview { export interface TypeshedSettingChange { key: TypeshedSettingKey; - before: TypeshedSettingValue | undefined; - after: TypeshedSettingValue | undefined; + before: string | undefined; + after: string | undefined; } export interface ApplyConfigurationRequest { @@ -315,8 +286,12 @@ export interface TypeshedLicenseDocument { readOnly: boolean; } +/** + * Downloads return the refreshed snapshot immediately (lifecycle Downloading); + * completion arrives as TypeshedStatusChanged + ConfigurationChanged. No action + * returns a preview — a download is not a configuration edit. + */ export type TypeshedActionResult = - | { kind: "Preview"; preview: ConfigurationPreview } | { kind: "Snapshot"; snapshot: ConfigurationSnapshot } | { kind: "License"; license: TypeshedLicenseDocument }; diff --git a/vscode-extension/src/configuration-editor-script-core.ts b/vscode-extension/src/configuration-editor-script-core.ts index b60a6af3..f6cb440f 100644 --- a/vscode-extension/src/configuration-editor-script-core.ts +++ b/vscode-extension/src/configuration-editor-script-core.ts @@ -20,7 +20,6 @@ export const CONFIGURATION_EDITOR_SCRIPT_CORE = String.raw` let activeTag; let selectedRuleCode; let lastFocusedRule; - let overlayWasBlocking = false; // Which navigation view is visible. Rules is the default so the editor // opens on the tag-first rule browser exactly as before. let activeSection = 'rules'; @@ -107,11 +106,13 @@ export const CONFIGURATION_EDITOR_SCRIPT_CORE = String.raw` function typeshedRemove(name) { return { kind: 'RemoveTypeshedSetting', key: typeshedKey(name) }; } + // 'value' is a bare String in the model ([LSPCFGED-TYPESHED], + // models/configuration_editor.td). It was a tagged union while typeshed + // settings had non-text kinds; every surviving key is text-valued, so a + // { kind: 'Text', ... } wrapper is now an unknown shape the decoder drops, + // silently discarding the user's edit. function typeshedSetText(name, value) { - return { kind: 'SetTypeshedSetting', key: typeshedKey(name), value: { kind: 'Text', value } }; - } - function typeshedSetBoolean(name, value) { - return { kind: 'SetTypeshedSetting', key: typeshedKey(name), value: { kind: 'Boolean', value } }; + return { kind: 'SetTypeshedSetting', key: typeshedKey(name), value }; } function selectedRule() { return snapshot && snapshot.rules.find((rule) => rule.descriptor.code === selectedRuleCode); diff --git a/vscode-extension/src/configuration-editor-script-events.ts b/vscode-extension/src/configuration-editor-script-events.ts index 28637f7a..cc65c48e 100644 --- a/vscode-extension/src/configuration-editor-script-events.ts +++ b/vscode-extension/src/configuration-editor-script-events.ts @@ -89,7 +89,13 @@ export const CONFIGURATION_EDITOR_SCRIPT_EVENTS = String.raw` const folderKey = target.dataset.pickTypeshedFolder; if (folderKey) { vscode.postMessage({ type: 'pickTypeshedFolder', key: folderKey }); return; } const typeshedAction = target.dataset.typeshedAction; - if (typeshedAction) { vscode.postMessage({ type: 'typeshedAction', action: typeshedAction }); return; } + if (typeshedAction) { + // The invoking button goes busy at once; nothing else changes + // ([LSPCFGED-TYPESHED-DOWNLOAD]). + typeshedActionStarted(typeshedAction, target); + vscode.postMessage({ type: 'typeshedAction', action: typeshedAction }); + return; + } const tag = target.dataset.tag; if (tag) { selectTag(tag); return; } const code = target.dataset.showRule; diff --git a/vscode-extension/src/configuration-editor-script-render.ts b/vscode-extension/src/configuration-editor-script-render.ts index 1a71660d..5c7d6a71 100644 --- a/vscode-extension/src/configuration-editor-script-render.ts +++ b/vscode-extension/src/configuration-editor-script-render.ts @@ -334,36 +334,25 @@ export const CONFIGURATION_EDITOR_SCRIPT_RENDER = String.raw` if (!dialog.open) dialog.showModal(); announce('Preview ready: ' + formatNumber(preview.changes.length + preview.typeshedChanges.length) + ' setting(s) change'); } - function renderOverlay() { - const overlay = byId('state-overlay'); - const blocking = !snapshot || ['loading', 'applying', 'error', 'conflict', 'unsupported'].includes(editorState.phase); - overlay.hidden = !blocking; - byId('shell').inert = blocking; - document.querySelector('body > header').inert = blocking; - document.querySelector('main').setAttribute('aria-busy', String(blocking)); - if (!blocking) { - if (overlayWasBlocking) { - const recovery = activeSection === 'rules' - ? byId('rule-search') - : document.querySelector('[data-section="' + activeSection + '"] h2'); - if (recovery) window.requestAnimationFrame(() => recovery.focus()); - } - overlayWasBlocking = false; - return; - } - const previewDialog = byId('preview-dialog'); - if (previewDialog.open) previewDialog.close(); + // The editor never blocks itself: failures and first loads render as an + // inline notice row while every control on screen stays live. There is no + // full-panel overlay, no modal, and no lock screen + // ([LSPCFGED-TYPESHED-DOWNLOAD]). + function renderNotice() { + const notice = byId('state-notice'); + const phase = editorState.phase; + const attention = ['error', 'conflict', 'unsupported'].includes(phase); + notice.hidden = !attention && snapshot !== undefined; + notice.dataset.phase = phase; + if (notice.hidden) return; const titles = { - loading: 'Reading project configuration', applying: 'Applying configuration', error: 'Configuration unavailable', + loading: 'Reading project configuration', error: 'Configuration unavailable', conflict: 'The project changed', unsupported: 'Update Basilisk to continue', idle: 'Connecting to Basilisk', }; - byId('state-title').textContent = titles[editorState.phase] || 'Working…'; - byId('state-message').textContent = editorState.message || 'Waiting for the language server.'; - byId('state-symbol').textContent = editorState.phase === 'conflict' ? '↻' : editorState.phase === 'error' ? '!' : 'B'; - byId('state-action').hidden = !['error', 'conflict'].includes(editorState.phase); - byId('state-open-raw').hidden = !editorState.repairUri; - if (!overlayWasBlocking) window.requestAnimationFrame(() => overlay.focus()); - overlayWasBlocking = true; + byId('notice-title').textContent = titles[phase] || 'Working…'; + byId('notice-message').textContent = editorState.message || 'Waiting for the language server.'; + byId('notice-retry').hidden = !['error', 'conflict'].includes(phase); + byId('notice-open-raw').hidden = !editorState.repairUri; } function renderState(nextState) { editorState = nextState || { phase: 'error', message: 'Invalid editor state' }; @@ -374,7 +363,7 @@ export const CONFIGURATION_EDITOR_SCRIPT_RENDER = String.raw` status.dataset.phase = editorState.phase; status.textContent = editorState.message || editorState.phase; if (snapshot) renderSnapshot(); - renderOverlay(); + renderNotice(); if (preview && editorState.phase === 'preview') renderPreview(); const dialog = byId('preview-dialog'); if (editorState.phase !== 'preview' && dialog.open) dialog.close(); diff --git a/vscode-extension/src/configuration-editor-script-typeshed.ts b/vscode-extension/src/configuration-editor-script-typeshed.ts index e3aba83a..94ed7b47 100644 --- a/vscode-extension/src/configuration-editor-script-typeshed.ts +++ b/vscode-extension/src/configuration-editor-script-typeshed.ts @@ -1,20 +1,15 @@ -// Implements [LSPCFGED-TYPESHED] standard-library source controls. +// Implements [LSPCFGED-TYPESHED] / [LSPCFGED-TYPESHED-DOWNLOAD] standard-library source controls. /** Typeshed fragment of the dependency-free webview runtime. */ export const CONFIGURATION_EDITOR_SCRIPT_TYPESHED = String.raw` - // The three mutually exclusive sources. The server states which one is - // ACTIVE (carrying the value that defines it) and whether a commit can be - // pinned; the copy below is client presentation, not server state. + // The two mutually exclusive sources and no third ([LSPCFGED-TYPESHED]). + // The server states which one is ACTIVE (carrying the value that defines + // it); the copy below is client presentation, not server state. const SOURCE_CHOICES = [ - { - mode: 'Latest', - label: 'Latest', - description: 'Track the newest python/typeshed commit on every acquisition.', - }, { mode: 'ExactCommit', label: 'Pinned commit', - description: 'Freeze the active commit so every machine resolves the identical standard library.', + description: 'Freeze one python/typeshed commit so every machine resolves the identical standard library.', }, { mode: 'CustomFolder', @@ -24,36 +19,43 @@ export const CONFIGURATION_EDITOR_SCRIPT_TYPESHED = String.raw` ]; const COMMIT_PATTERN = /^[0-9a-f]{40}$/i; let advancedOpen = false; - const ACQUIRING_HINT = 'A standard library is being acquired; the source is locked until it settles.'; + // Which download button is waiting on the server, so the running + // download's spinner lands on the button that started it and on nothing + // else ([LSPCFGED-TYPESHED-DOWNLOAD]). + let pendingDownload; function typeshedState() { return snapshot.typeshed; } - function typeshedSourceMode() { return kind(typeshedState().source, 'Latest'); } - function typeshedAcquiring() { - return kind(typeshedState().status.lifecycle, 'Ready') === 'Acquiring'; - } + function typeshedSourceMode() { return kind(typeshedState().source, 'ExactCommit'); } + function typeshedLifecycle() { return kind(typeshedState().status.lifecycle, 'Ready'); } + function typeshedDownloading() { return typeshedLifecycle() === 'Downloading'; } function shortCommit(commit) { return commit ? commit.slice(0, 12) : ''; } - // Why a source cannot be chosen right now, or '' when it can. Pinning - // writes the ACTIVE commit, so it needs one to exist. - function sourceUnavailable(mode) { - if (typeshedAcquiring()) return ACQUIRING_HINT; - if (mode !== 'ExactCommit' || typeshedSourceMode() === 'ExactCommit') return ''; - if (typeshedState().pinnableCommit) return ''; - return typeshedSourceMode() === 'CustomFolder' - ? 'A custom folder has no upstream commit to pin. Choose Latest first.' - : 'Available once a downloaded standard library is active.'; - } + // The active source is the whole trust story — there are no separate + // transport or provenance rows ([LSPCFGED-TYPESHED-SERVICE-INFO]). function statusRows() { const status = typeshedState().status; - const lifecycle = kind(status.lifecycle, 'Acquiring'); const commit = status.commitIdentity; return [ - ['State', status.blockedReason ? lifecycle + ' — ' + status.blockedReason : lifecycle], - ['Active source', kind(status.activeSource, 'Acquiring') + (commit ? ' · ' + shortCommit(commit) : '')], - ['Delivery', kind(status.transport, 'Pending') + ' · ' + kind(status.provenance, 'Pending') - + (status.signedRelease ? ' · signed release' : '')], - ['License', kind(status.licenseStatus, 'Acquiring')], + ['State', typeshedLifecycle()], + ['Active source', kind(status.activeSource, 'Pending') + (commit ? ' · ' + shortCommit(commit) : '')], + ['License', kind(status.licenseStatus, 'Unavailable')], ]; } + // A missing source is a persistent row IN the panel carrying its own fix — + // never an overlay, never a lock screen ([LSPCFGED-TYPESHED-DOWNLOAD]). + // The row survives while the fix itself downloads, so the busy button + // keeps its home until the source settles. + function noSourceRow() { + const row = document.createElement('div'); + row.className = 'typeshed-no-source'; + row.setAttribute('role', 'alert'); + row.append( + textNode('strong', 'NO SOURCE'), + textNode('span', typeshedState().status.noSourceReason + || 'The pinned commit is not on this machine; analysis is paused until it is downloaded.'), + downloadButton('DownloadPinned', 'Download pinned'), + ); + return row; + } function renderTypeshedStatus() { const target = byId('typeshed-status'); clear(target); @@ -65,7 +67,12 @@ export const CONFIGURATION_EDITOR_SCRIPT_TYPESHED = String.raw` row.dataset.severity = kind(warning.severity, 'Advisory').toLowerCase(); target.append(row); }); + if (typeshedLifecycle() === 'NoSource' || (typeshedDownloading() && pendingDownload === 'DownloadPinned')) { + target.append(noSourceRow()); + } } + // Both sources stay choosable at all times: reading and writing + // configuration never waits on the network ([LSPCFGED-TYPESHED-DOWNLOAD]). function sourceChoice(choice) { const label = document.createElement('label'); label.className = 'source-choice'; @@ -75,10 +82,7 @@ export const CONFIGURATION_EDITOR_SCRIPT_TYPESHED = String.raw` input.value = choice.mode; input.dataset.typeshedSource = choice.mode; input.checked = typeshedSourceMode() === choice.mode; - const unavailable = sourceUnavailable(choice.mode); - input.disabled = unavailable !== '' && !input.checked; - label.append(input, textNode('span', choice.label)); - label.append(textNode('small', input.disabled ? unavailable : choice.description)); + label.append(input, textNode('span', choice.label), textNode('small', choice.description)); return label; } function renderSourceChoices(target) { @@ -94,9 +98,9 @@ export const CONFIGURATION_EDITOR_SCRIPT_TYPESHED = String.raw` // other source's field exists in the DOM ([LSPCFGED-TYPESHED]). function renderSourceValue(target) { const source = typeshedState().source; - const mode = kind(source, 'Latest'); - if (mode === 'ExactCommit') { target.append(commitField(source.commit)); return; } - if (mode === 'CustomFolder') { target.append(folderField('TypeshedPath', 'Folder', source.path)); } + const mode = kind(source, 'ExactCommit'); + if (mode === 'CustomFolder') { target.append(folderField('TypeshedPath', 'Folder', source.path)); return; } + target.append(commitField(source.commit)); } function commitField(commit) { const field = document.createElement('label'); @@ -105,7 +109,6 @@ export const CONFIGURATION_EDITOR_SCRIPT_TYPESHED = String.raw` const input = document.createElement('input'); input.type = 'text'; input.value = commit || ''; - input.disabled = typeshedAcquiring(); input.dataset.typeshedCommit = 'TypeshedCommit'; input.autocomplete = 'off'; input.spellcheck = false; @@ -129,24 +132,15 @@ export const CONFIGURATION_EDITOR_SCRIPT_TYPESHED = String.raw` input.dataset.typeshedPath = key; const choose = textNode('button', value ? 'Change…' : 'Choose folder…', 'secondary'); choose.type = 'button'; - choose.disabled = typeshedAcquiring(); choose.dataset.pickTypeshedFolder = key; picker.append(input, choose); field.append(picker); return field; } - function toggleField(key, label, description, checked) { - const field = document.createElement('label'); - field.className = 'typeshed-toggle'; - const input = document.createElement('input'); - input.type = 'checkbox'; - input.checked = checked; - input.disabled = typeshedAcquiring(); - input.dataset.typeshedBoolean = key; - field.append(input, textNode('span', label), textNode('small', description)); - return field; - } - function advancedDownloads(downloads) { + // A custom folder downloads nothing, so it has no store folder and the + // Advanced disclosure does not exist ([LSPCFGED-TYPESHED]). + function renderStore(target) { + if (typeshedSourceMode() !== 'ExactCommit') return; const details = document.createElement('details'); details.className = 'typeshed-advanced'; // Every write re-renders from the fresh snapshot, so the disclosure has @@ -156,31 +150,8 @@ export const CONFIGURATION_EDITOR_SCRIPT_TYPESHED = String.raw` const summary = document.createElement('summary'); summary.textContent = 'Advanced'; details.append(summary); - const url = document.createElement('label'); - url.className = 'typeshed-field'; - url.append(textNode('span', 'Archive mirror'), textNode('small', 'HTTPS template containing exactly one {sha}.')); - const input = document.createElement('input'); - input.type = 'text'; - input.value = downloads.archiveUrl || ''; - input.placeholder = 'https://example.test/{sha}.zip'; - input.disabled = typeshedAcquiring(); - input.dataset.typeshedText = 'TypeshedUrl'; - input.autocomplete = 'off'; - input.spellcheck = false; - url.append(input); - details.append(url, folderField('TypeshedCachePath', 'Cache folder', downloads.cacheFolder)); - return details; - } - // A user-managed folder downloads nothing, so the server sends no download - // policy and none of these controls exists. - function renderDownloads(target) { - const downloads = typeshedState().downloads; - if (!downloads) return; - target.append( - toggleField('TypeshedCache', 'Reuse downloads', 'Off re-downloads, validates, and discards.', downloads.reuseDownloads), - toggleField('TypeshedVerify', 'Verify content', 'Attest content to the selected Git tree.', downloads.verifyContent), - advancedDownloads(downloads), - ); + details.append(folderField('TypeshedStorePath', 'Store folder', typeshedState().storeFolder)); + target.append(details); } function typeshedActionButton(action, label, enabled) { const button = textNode('button', label, 'secondary'); @@ -189,34 +160,82 @@ export const CONFIGURATION_EDITOR_SCRIPT_TYPESHED = String.raw` button.dataset.typeshedAction = action; return button; } + function markBusy(button) { + button.classList.add('busy'); + button.disabled = true; + button.setAttribute('aria-busy', 'true'); + } + // A running download shows progress ON the button that started it; a + // second download cannot start, and nothing else is blocked + // ([LSPCFGED-TYPESHED-DOWNLOAD]). + function downloadButton(action, label) { + const button = typeshedActionButton(action, label, !typeshedDownloading()); + if (typeshedDownloading() && (pendingDownload || 'DownloadLatest') === action) markBusy(button); + return button; + } + /** The spinner must appear at once — before the server's Downloading state lands. */ + function typeshedActionStarted(action, button) { + if (action !== 'DownloadLatest' && action !== 'DownloadPinned') return; + pendingDownload = action; + markBusy(button); + } + // A Typeshed write re-renders this section from the fresh snapshot; the + // rebuild must not eat the user's focus or caret ([LSPCFGED-TYPESHED]). + function typeshedFocusSelector(active) { + if (!active || !active.dataset) return undefined; + if (active.dataset.typeshedSource) return '[data-typeshed-source="' + active.dataset.typeshedSource + '"]'; + if (active.dataset.typeshedCommit) return '[data-typeshed-commit]'; + if (active.dataset.pickTypeshedFolder) return '[data-pick-typeshed-folder="' + active.dataset.pickTypeshedFolder + '"]'; + if (active.dataset.typeshedAction) return '[data-typeshed-action="' + active.dataset.typeshedAction + '"]'; + return undefined; + } + function saveTypeshedFocus() { + const active = document.activeElement; + const selector = typeshedFocusSelector(active); + if (!selector) return undefined; + const caret = typeof active.selectionStart === 'number'; + return { + selector, + start: caret ? active.selectionStart : undefined, + end: caret ? active.selectionEnd : undefined, + }; + } + function restoreTypeshedFocus(saved) { + if (!saved) return; + const control = document.querySelector(saved.selector); + if (!control) return; + control.focus({ preventScroll: true }); + if (saved.start !== undefined && typeof control.setSelectionRange === 'function') { + control.setSelectionRange(saved.start, saved.end === undefined ? saved.start : saved.end); + } + } function renderTypeshedControls() { + if (!typeshedDownloading()) pendingDownload = undefined; + const focus = saveTypeshedFocus(); renderTypeshedStatus(); const controls = byId('typeshed-controls'); clear(controls); renderSourceChoices(controls); renderSourceValue(controls); - renderDownloads(controls); + renderStore(controls); const actions = byId('typeshed-actions'); clear(actions); actions.append( - typeshedActionButton('AcquireFresh', 'Acquire fresh', !typeshedAcquiring()), + downloadButton('DownloadLatest', 'Download latest'), typeshedActionButton('ViewLicense', 'View license', typeshedState().licenseAvailable), ); + restoreTypeshedFocus(focus); } - // Choosing a source is one atomic transition, so no combination of source - // values can ever be written: Latest CLEARS both pins, pinning writes the - // active commit and clears any folder, and a folder clears the pin. + // Choosing a source is one atomic transition ([LSPCFGED-TYPESHED]): + // pinning clears any folder and a folder clears the pin, so no + // combination of source values can ever be written. Nothing locks while + // the mutation round-trips — every control re-renders from the snapshot + // the write returns. function chooseTypeshedSource(mode) { - // Acquisition is one atomic source transition: nothing may race it. - if (typeshedAcquiring() || mode === typeshedSourceMode()) return; - if (mode === 'Latest') { - postPreview([typeshedRemove('TypeshedCommit'), typeshedRemove('TypeshedPath')]); - announce('Following the latest python/typeshed commit'); - return; - } + if (mode === typeshedSourceMode()) return; if (mode === 'ExactCommit') { - vscode.postMessage({ type: 'typeshedAction', action: 'PinCurrent' }); - announce('Pinning the active commit'); + postPreview([typeshedRemove('TypeshedPath')]); + announce('Using the pinned standard-library commit'); return; } vscode.postMessage({ type: 'pickTypeshedFolder', key: 'TypeshedPath' }); @@ -243,17 +262,6 @@ export const CONFIGURATION_EDITOR_SCRIPT_TYPESHED = String.raw` function typeshedChanged(target) { if (target.dataset.typeshedSource) { chooseTypeshedSource(target.value); return true; } if (target.dataset.typeshedCommit) { commitEdited(target); return true; } - if (target.dataset.typeshedBoolean) { - postPreview([typeshedSetBoolean(target.dataset.typeshedBoolean, target.checked)]); - return true; - } - if (target.dataset.typeshedText) { - const value = target.value.trim(); - postPreview(value === '' - ? [typeshedRemove(target.dataset.typeshedText)] - : [typeshedSetText(target.dataset.typeshedText, value)]); - return true; - } return false; } `; diff --git a/vscode-extension/src/configuration-editor-state.ts b/vscode-extension/src/configuration-editor-state.ts index 94a7425a..b4f1aa0f 100644 --- a/vscode-extension/src/configuration-editor-state.ts +++ b/vscode-extension/src/configuration-editor-state.ts @@ -109,15 +109,13 @@ function isTypeshedWarning(value: unknown): boolean { } function hasTypeshedStateKinds(fields: Record): boolean { - return hasKind(fields.lifecycle, ["Acquiring", "Ready", "Blocked"]) - && hasKind(fields.licenseStatus, ["Acquiring", "Unavailable", "Approved", "Changed", "NotSupplied"]) - && hasKind(fields.provenance, ["Pending", "GithubTlsAttested", "Unverified", "BundleVetted", "UserManaged"]) - && hasOptionalKind(fields.activeSource, ["Custom", "ExactCommit", "Latest", "Bundled"]) - && hasOptionalKind(fields.transport, ["CustomPath", "EmbeddedZip", "Codeload", "Mirror"]); + return hasKind(fields.lifecycle, ["Downloading", "Ready", "NoSource"]) + && hasKind(fields.licenseStatus, ["Unavailable", "Approved", "Changed", "NotSupplied"]) + && hasOptionalKind(fields.activeSource, ["Custom", "ExactCommit", "Bundled"]); } function hasTypeshedIdentityFields(fields: Record): boolean { - return isOptionalString(fields.blockedReason) + return isOptionalString(fields.noSourceReason) && isOptionalString(fields.commitIdentity); } @@ -126,7 +124,6 @@ function isTypeshedStatus(value: unknown): boolean { const fields = value as Record; return hasTypeshedStateKinds(fields) && hasTypeshedIdentityFields(fields) - && typeof fields.signedRelease === "boolean" && Array.isArray(fields.warnings) && fields.warnings.every(isTypeshedWarning); } diff --git a/vscode-extension/src/configuration-editor-styles.ts b/vscode-extension/src/configuration-editor-styles.ts index ad2bbdc8..c26b9322 100644 --- a/vscode-extension/src/configuration-editor-styles.ts +++ b/vscode-extension/src/configuration-editor-styles.ts @@ -27,6 +27,8 @@ export const CONFIGURATION_EDITOR_STYLES = ` html, body { height: 100%; } body { margin: 0; + display: flex; + flex-direction: column; overflow: hidden; background: var(--bg); color: var(--text); @@ -145,7 +147,23 @@ export const CONFIGURATION_EDITOR_STYLES = ` .primary { background: var(--vscode-button-background); color: var(--vscode-button-foreground); } .primary:hover { background: var(--vscode-button-hoverBackground); } - #shell { height: calc(100vh - 74px); display: grid; grid-template-columns: 190px minmax(0, 1fr); } + #state-notice { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 9px 20px; + background: var(--bsk-sky-soft); + border-bottom: 1px solid var(--border); + } + #state-notice[hidden] { display: none; } + #state-notice[data-phase="error"], + #state-notice[data-phase="conflict"], + #state-notice[data-phase="unsupported"] { background: var(--bsk-orange-soft); } + #notice-message { color: var(--muted); } + .notice-actions { display: flex; gap: 8px; margin-left: auto; } + + #shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: 190px minmax(0, 1fr); } #section-nav { padding: 16px 10px; overflow-y: auto; background: var(--surface); border-right: 1px solid var(--border); } #section-nav button { width: 100%; display: flex; align-items: center; gap: 10px; margin-bottom: 3px; padding: 8px 10px; background: transparent; border-color: transparent; border-radius: 7px; color: var(--muted); text-align: left; } #section-nav button:hover { background: var(--vscode-list-hoverBackground); color: var(--text); } @@ -292,35 +310,31 @@ export const CONFIGURATION_EDITOR_STYLES = ` border-radius: var(--radius); box-shadow: 0 1px 0 color-mix(in srgb, var(--text) 5%, transparent); } - #state-overlay { - position: fixed; - z-index: 30; - inset: 0; - display: grid; - place-items: center; - padding: 24px; - background: color-mix(in srgb, var(--bg) 94%, transparent); - } - #state-overlay[hidden] { display: none; } - #state-card { width: min(480px, 100%); padding: 26px; text-align: center; } - #state-symbol { width: 44px; height: 44px; display: grid; place-items: center; margin: 0 auto 12px; border-radius: 50%; background: var(--bsk-orange-soft); color: var(--bsk-orange); font-size: 20px; } - #state-card h2 { margin: 0 0 5px; } - #state-card p { margin: 0 0 14px; color: var(--muted); } - .overlay-actions { justify-content: center; } #typeshed-status dl { display: grid; grid-template-columns: minmax(100px, auto) minmax(0, 1fr); gap: 4px 12px; } #typeshed-status dt { color: var(--muted); } #typeshed-status dd { margin: 0; overflow-wrap: anywhere; } .typeshed-warning { padding: 8px 10px; border-left: 3px solid var(--bsk-orange); background: var(--bsk-orange-soft); } .typeshed-warning[data-severity="high"] { border-left-color: var(--vscode-errorForeground); } + .typeshed-no-source { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-top: 8px; + padding: 10px 12px; + border-left: 3px solid var(--vscode-errorForeground); + background: color-mix(in srgb, var(--vscode-errorForeground) 10%, transparent); + } + .typeshed-no-source strong { font-size: 11px; letter-spacing: .06em; } + .typeshed-no-source span { flex: 1 1 220px; } #typeshed-controls { display: grid; gap: 14px; max-width: 620px; margin: 16px 0; } - .typeshed-source { display: grid; gap: 8px; border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin: 0; } + .typeshed-source { display: grid; gap: 8px; border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; margin: 0; } .typeshed-source legend { padding: 0 6px; color: var(--muted); } - .source-choice, .typeshed-toggle { + .source-choice { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; align-items: baseline; } - .source-choice input, .typeshed-toggle input { grid-row: 1 / span 2; align-self: center; margin: 0; } - .source-choice small, .typeshed-toggle small { grid-column: 2; color: var(--muted); } - .source-choice:has(input:disabled) span, .typeshed-toggle:has(input:disabled) span { color: var(--muted); } + .source-choice input { grid-row: 1 / span 2; align-self: center; margin: 0; } + .source-choice small { grid-column: 2; color: var(--muted); } .typeshed-field { display: grid; gap: 5px; } .typeshed-field > span { font-weight: 600; } .typeshed-field > small { color: var(--muted); } @@ -328,12 +342,25 @@ export const CONFIGURATION_EDITOR_STYLES = ` .typeshed-field input[aria-invalid="true"] { outline: 1px solid var(--vscode-errorForeground); } .field-error { color: var(--vscode-errorForeground); } .field-error[hidden] { display: none; } - .typeshed-advanced { border-top: 1px solid var(--line); padding-top: 10px; display: grid; gap: 12px; } + .typeshed-advanced { border-top: 1px solid var(--border); padding-top: 10px; display: grid; gap: 12px; } .typeshed-advanced summary { cursor: pointer; color: var(--muted); } .typeshed-advanced[open] summary { margin-bottom: 2px; } .path-picker { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; } + .busy::after { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + margin-left: 7px; + vertical-align: -1px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin .8s linear infinite; + } @keyframes breathe { 50% { opacity: .25; transform: scale(.75); } } + @keyframes spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } } @@ -355,7 +382,7 @@ export const CONFIGURATION_EDITOR_STYLES = ` @media (max-width: 720px) { body > header { min-height: 64px; padding: 9px 12px; } #status-pill { display: none; } - #shell { height: calc(100vh - 64px); grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } + #shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } #section-nav { display: flex; gap: 4px; padding: 7px; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--border); } #section-nav button { width: auto; flex: 0 0 auto; justify-content: center; margin: 0; } .preview-change { grid-template-columns: minmax(0, 1fr); } diff --git a/vscode-extension/src/configuration-editor-transport.ts b/vscode-extension/src/configuration-editor-transport.ts new file mode 100644 index 00000000..22c38fc1 --- /dev/null +++ b/vscode-extension/src/configuration-editor-transport.ts @@ -0,0 +1,104 @@ +// Implements [VSIX-CONFIGURATION-EDITOR] / [VSIX-CONFIGURATION-EDITOR-THIN-SHELL]. +/** LSP seam for the configuration editor: capability probe, transport, root choice. + * + * Split out of `configuration-editor.ts` so every file behind + * [VSIX-CONFIGURATION-EDITOR-FILES] stays under the repository's 500-LOC ceiling. + * Everything here is about talking to the server or picking which workspace root + * to talk about — none of it touches the panel, so the host file keeps only + * lifecycle and intent routing. + */ + +import * as vscode from "vscode"; +import type { LanguageClient } from "vscode-languageclient/node"; +import type { + ApplyConfigurationRequest, + ConfigurationPreview, + ConfigurationSnapshot, + PreviewConfigurationRequest, + RuleOccurrencesRequest, + RuleOccurrencesResponse, + TypeshedActionRequest, + TypeshedActionResult, +} from "./configuration-editor-model"; +import type { Store } from "./store"; + +const SNAPSHOT_METHOD = "basilisk/configurationSnapshot"; +const PREVIEW_METHOD = "basilisk/previewConfigurationChange"; +const APPLY_METHOD = "basilisk/applyConfigurationChange"; +const OCCURRENCES_METHOD = "basilisk/ruleOccurrences"; +const TYPESHED_ACTION_METHOD = "basilisk/typeshedAction"; +const EXECUTE_COMMAND_METHOD = "workspace/executeCommand"; + +/** Typed transport seam: production uses LanguageClient; tests can inject a fake. */ +export interface ConfigurationEditorTransport { + snapshot(rootUri: string): Promise; + preview(request: PreviewConfigurationRequest): Promise; + apply(request: ApplyConfigurationRequest): Promise; + occurrences(request: RuleOccurrencesRequest): Promise; + typeshedAction(request: TypeshedActionRequest): Promise; + executeCommand(command: string, args: readonly unknown[]): Promise; +} + +interface ExperimentalCapabilities { + readonly basilisk?: { + readonly configurationEditor?: unknown; + }; +} + +/** + * [LSPARCH-CONFIG-EDITOR-PROTOCOL]: the editor ships with the server, so the + * capability is pure presence — `configurationEditor` advertised truthy. + */ +export function supportsConfigurationEditor(client: LanguageClient | undefined): boolean { + const experimental = client?.initializeResult?.capabilities.experimental as unknown; + if (typeof experimental !== "object" || experimental === null) { return false; } + const capability = (experimental as ExperimentalCapabilities).basilisk?.configurationEditor; + return capability !== undefined && capability !== null && capability !== false; +} + +export function clientTransport(store: Store): ConfigurationEditorTransport { + function runningClient(): LanguageClient { + const client = store.client.value; + if (client?.isRunning() !== true) { throw new Error("The Basilisk language server is not running."); } + return client; + } + return { + async snapshot(rootUri: string): Promise { + return runningClient().sendRequest(SNAPSHOT_METHOD, { rootUri }); + }, + async preview(request: PreviewConfigurationRequest): Promise { + return runningClient().sendRequest(PREVIEW_METHOD, request); + }, + async apply(request: ApplyConfigurationRequest): Promise { + return runningClient().sendRequest(APPLY_METHOD, request); + }, + async occurrences(request: RuleOccurrencesRequest): Promise { + return runningClient().sendRequest(OCCURRENCES_METHOD, request); + }, + async typeshedAction(request: TypeshedActionRequest): Promise { + return runningClient().sendRequest(TYPESHED_ACTION_METHOD, request); + }, + async executeCommand(command: string, args: readonly unknown[]): Promise { + await runningClient().sendRequest(EXECUTE_COMMAND_METHOD, { command, arguments: args }); + }, + }; +} + +function fileWorkspaceRoot(): vscode.WorkspaceFolder | undefined { + const uri = vscode.window.activeTextEditor?.document.uri; + return uri === undefined ? undefined : vscode.workspace.getWorkspaceFolder(uri); +} + +/** Choose an explicit root; active-editor ownership wins in a multi-root workspace. */ +export async function selectConfigurationRoot(): Promise { + const activeRoot = fileWorkspaceRoot(); + if (activeRoot !== undefined) { return activeRoot.uri.toString(); } + const roots = vscode.workspace.workspaceFolders ?? []; + if (roots.length === 1) { return roots[0]?.uri.toString(); } + if (roots.length === 0) { return undefined; } + const choice = await vscode.window.showQuickPick( + roots.map((root) => ({ label: root.name, detail: root.uri.fsPath, rootUri: root.uri.toString() })), + { title: "Basilisk Configuration", placeHolder: "Choose the workspace configuration to edit" }, + ); + return choice?.rootUri; +} diff --git a/vscode-extension/src/configuration-editor-typeshed.ts b/vscode-extension/src/configuration-editor-typeshed.ts index 6944411a..4fc5fa94 100644 --- a/vscode-extension/src/configuration-editor-typeshed.ts +++ b/vscode-extension/src/configuration-editor-typeshed.ts @@ -31,29 +31,26 @@ export class TypeshedEditorUi implements vscode.Disposable { public dispose(): void { this.registration.dispose(); this.provider.dispose(); } } -export async function confirmVerificationOff(): Promise { - const accepted = await vscode.window.showWarningMessage( - "Disable Typeshed content verification? Safety, shape, and license gates will still run, but source status will report UNVERIFIED.", - { modal: true }, - "Disable verification", - ); - return accepted === "Disable verification"; -} - +/** + * The two directory-typed settings render with a native folder-picker rather + * than free text ([LSPCFGED-TYPESHED]). Choosing the source folder is one + * atomic transition — it also clears the pin; a cancelled picker writes + * nothing at all. + */ export async function pickTypeshedFolder( snapshot: ConfigurationSnapshot, - key: "TypeshedPath" | "TypeshedCachePath", + key: "TypeshedPath" | "TypeshedStorePath", ): Promise | undefined> { const selected = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, defaultUri: vscode.Uri.parse(snapshot.rootUri, true), - openLabel: key === "TypeshedPath" ? "Use Typeshed folder" : "Use cache folder", - title: key === "TypeshedPath" ? "Choose a Typeshed tree containing stdlib/" : "Choose the Typeshed cache folder", + openLabel: key === "TypeshedPath" ? "Use Typeshed folder" : "Use store folder", + title: key === "TypeshedPath" ? "Choose a Typeshed tree containing stdlib/" : "Choose the Typeshed store folder", }); const folder = selected?.[0]; if (folder === undefined) { return undefined; } const mutations: Extract["mutations"] = [{ - kind: "SetTypeshedSetting", key: { kind: key }, value: { kind: "Text", value: folder.fsPath }, + kind: "SetTypeshedSetting", key: { kind: key }, value: folder.fsPath, }]; if (key === "TypeshedPath") { mutations.push({ kind: "RemoveTypeshedSetting", key: { kind: "TypeshedCommit" } }); @@ -73,13 +70,3 @@ export function isTypeshedOnly( return intent.mutations.every((mutation) => mutation.kind === "SetTypeshedSetting" || mutation.kind === "RemoveTypeshedSetting"); } - -export function disablesTypeshedVerification( - intent: Extract, -): boolean { - return intent.mutations.some((mutation) => - mutation.kind === "SetTypeshedSetting" - && mutation.key.kind === "TypeshedVerify" - && mutation.value.kind === "Boolean" - && !mutation.value.value); -} diff --git a/vscode-extension/src/configuration-editor.ts b/vscode-extension/src/configuration-editor.ts index 2f1887fe..7d9694f5 100644 --- a/vscode-extension/src/configuration-editor.ts +++ b/vscode-extension/src/configuration-editor.ts @@ -3,7 +3,6 @@ import { effect } from "@preact/signals-core"; import * as vscode from "vscode"; -import type { LanguageClient } from "vscode-languageclient/node"; import { buildConfigurationEditorDocument } from "./configuration-editor-document"; import { configurationError, @@ -15,18 +14,15 @@ import { type ConfigurationEditorIntent, } from "./configuration-editor-intents"; import type { - ApplyConfigurationRequest, ConfigurationPreview, - ConfigurationSnapshot, - PreviewConfigurationRequest, RuleOccurrencesRequest, - RuleOccurrencesResponse, TypeshedActionRequest, - TypeshedActionResult, } from "./configuration-editor-model"; import { - confirmVerificationOff, - disablesTypeshedVerification, + clientTransport, + type ConfigurationEditorTransport, +} from "./configuration-editor-transport"; +import { isTypeshedOnly, pickTypeshedFolder, TypeshedEditorUi, @@ -39,90 +35,17 @@ export const CONFIGURATION_EDITOR_COMMAND = "basilisk.openConfigurationEditor"; /** Explorer context-menu entry on pyproject.toml ("Edit Config"). */ export const EDIT_CONFIG_COMMAND = "basilisk.editConfig"; export const CONFIGURATION_EDITOR_CONTEXT = "basilisk.configurationEditorSupported"; -const SNAPSHOT_METHOD = "basilisk/configurationSnapshot"; -const PREVIEW_METHOD = "basilisk/previewConfigurationChange"; -const APPLY_METHOD = "basilisk/applyConfigurationChange"; -const OCCURRENCES_METHOD = "basilisk/ruleOccurrences"; -const TYPESHED_ACTION_METHOD = "basilisk/typeshedAction"; -const EXECUTE_COMMAND_METHOD = "workspace/executeCommand"; const ADOPT_WORKSPACE_COMMAND = "basilisk.adoptWorkspace"; const FIX_WORKSPACE_COMMAND = "basilisk.fixWorkspace"; const CONFIGURATION_VIEW_TYPE = "basilisk.configurationEditor"; export { configurationRepairUri } from "./configuration-editor-errors"; - -/** Typed transport seam: production uses LanguageClient; tests can inject a fake. */ -export interface ConfigurationEditorTransport { - snapshot(rootUri: string): Promise; - preview(request: PreviewConfigurationRequest): Promise; - apply(request: ApplyConfigurationRequest): Promise; - occurrences(request: RuleOccurrencesRequest): Promise; - typeshedAction(request: TypeshedActionRequest): Promise; - executeCommand(command: string, args: readonly unknown[]): Promise; -} - -interface ExperimentalCapabilities { - readonly basilisk?: { - readonly configurationEditor?: unknown; - }; -} - -/** - * [LSPARCH-CONFIG-EDITOR-PROTOCOL]: the editor ships with the server, so the - * capability is pure presence — `configurationEditor` advertised truthy. - */ -export function supportsConfigurationEditor(client: LanguageClient | undefined): boolean { - const experimental = client?.initializeResult?.capabilities.experimental as unknown; - if (typeof experimental !== "object" || experimental === null) { return false; } - const capability = (experimental as ExperimentalCapabilities).basilisk?.configurationEditor; - return capability !== undefined && capability !== null && capability !== false; -} - -function clientTransport(store: Store): ConfigurationEditorTransport { - function runningClient(): LanguageClient { - const client = store.client.value; - if (client?.isRunning() !== true) { throw new Error("The Basilisk language server is not running."); } - return client; - } - return { - async snapshot(rootUri: string): Promise { - return runningClient().sendRequest(SNAPSHOT_METHOD, { rootUri }); - }, - async preview(request: PreviewConfigurationRequest): Promise { - return runningClient().sendRequest(PREVIEW_METHOD, request); - }, - async apply(request: ApplyConfigurationRequest): Promise { - return runningClient().sendRequest(APPLY_METHOD, request); - }, - async occurrences(request: RuleOccurrencesRequest): Promise { - return runningClient().sendRequest(OCCURRENCES_METHOD, request); - }, - async typeshedAction(request: TypeshedActionRequest): Promise { - return runningClient().sendRequest(TYPESHED_ACTION_METHOD, request); - }, - async executeCommand(command: string, args: readonly unknown[]): Promise { - await runningClient().sendRequest(EXECUTE_COMMAND_METHOD, { command, arguments: args }); - }, - }; -} - -function fileWorkspaceRoot(): vscode.WorkspaceFolder | undefined { - const uri = vscode.window.activeTextEditor?.document.uri; - return uri === undefined ? undefined : vscode.workspace.getWorkspaceFolder(uri); -} - -/** Choose an explicit root; active-editor ownership wins in a multi-root workspace. */ -export async function selectConfigurationRoot(): Promise { - const activeRoot = fileWorkspaceRoot(); - if (activeRoot !== undefined) { return activeRoot.uri.toString(); } - const roots = vscode.workspace.workspaceFolders ?? []; - if (roots.length === 1) { return roots[0]?.uri.toString(); } - if (roots.length === 0) { return undefined; } - const choice = await vscode.window.showQuickPick( - roots.map((root) => ({ label: root.name, detail: root.uri.fsPath, rootUri: root.uri.toString() })), - { title: "Basilisk Configuration", placeHolder: "Choose the workspace configuration to edit" }, - ); - return choice?.rootUri; -} +// The LSP seam lives next door ([VSIX-CONFIGURATION-EDITOR-FILES]); re-exported +// here so callers keep importing the editor's public surface from one module. +export { + selectConfigurationRoot, + supportsConfigurationEditor, + type ConfigurationEditorTransport, +} from "./configuration-editor-transport"; /** Singleton editor tab and intent router; all configuration state remains in Store. */ export class ConfigurationEditorController implements vscode.Disposable { @@ -279,12 +202,6 @@ export class ConfigurationEditorController implements vscode.Disposable { const state = this.store.configurationEditor.value; const snapshot = state.snapshot; if (snapshot === undefined || state.phase === "applying") { return; } - if (disablesTypeshedVerification(intent)) { - if (!await confirmVerificationOff()) { - void this.panel.postMessage({ type: "state", state }); - return; - } - } const generation = this.loadGeneration; const previewGeneration = ++this.previewGeneration; this.store.beginConfigurationPreview(); @@ -307,7 +224,7 @@ export class ConfigurationEditorController implements vscode.Disposable { } } - private async pickTypeshedFolder(key: "TypeshedPath" | "TypeshedCachePath"): Promise { + private async pickTypeshedFolder(key: "TypeshedPath" | "TypeshedStorePath"): Promise { const state = this.store.configurationEditor.value; if (state.snapshot === undefined) { return; } const intent = await pickTypeshedFolder(state.snapshot, key); @@ -328,10 +245,11 @@ export class ConfigurationEditorController implements vscode.Disposable { action, }); if (this.requestIsStale(generation, snapshot.rootUri)) { return; } - if (result.kind === "Preview") { - // PinCurrent writes the active commit: a source choice, applied now. - await this.applyPreview(result.preview); - } else if (result.kind === "Snapshot") { + // A download returns the refreshed snapshot at once (lifecycle + // Downloading); completion arrives as ordinary server notifications. + // No action returns a preview — a download is not a configuration edit + // ([LSPCFGED-TYPESHED-DOWNLOAD]). + if (result.kind === "Snapshot") { this.store.acceptConfigurationSnapshot(result.snapshot); } else { await this.typeshedUi.showLicense(result.license); diff --git a/vscode-extension/src/info-panel.ts b/vscode-extension/src/info-panel.ts index bd822455..7e0a1a25 100644 --- a/vscode-extension/src/info-panel.ts +++ b/vscode-extension/src/info-panel.ts @@ -212,9 +212,11 @@ function statusKind(value: { readonly kind: string } | undefined): string { return value?.kind ?? "Pending"; } +// The spinner belongs to the running download only; NO SOURCE is a persistent +// error state, never a spinner ([LSPCFGED-TYPESHED-SERVICE-INFO]). function typeshedLifecycleIcon(status: TypeshedStatusState): string { - if (status.lifecycle.kind === "Acquiring") { return "loading~spin"; } - if (status.lifecycle.kind === "Blocked") { return "error"; } + if (status.lifecycle.kind === "Downloading") { return "loading~spin"; } + if (status.lifecycle.kind === "NoSource") { return "error"; } return "database"; } @@ -227,15 +229,14 @@ function rootLabel(rootUri: string): string { } } +// The active source IS the whole trust story — there are no separate +// transport or provenance facts to repeat ([LSPCFGED-TYPESHED-SERVICE-INFO]). function typeshedTooltip(rootUri: string, status: TypeshedStatusState): string { return [ `Root: ${rootUri}`, `State: ${statusKind(status.lifecycle)}`, `Source: ${statusKind(status.activeSource)}`, `Commit: ${status.commitIdentity ?? "not available"}`, - `Transport: ${statusKind(status.transport)}`, - `Provenance: ${statusKind(status.provenance)}`, - `Signed release: ${status.signedRelease ? "yes" : "no"}`, `License: ${statusKind(status.licenseStatus)}`, ].join("\n"); } @@ -258,9 +259,9 @@ function typeshedSourceItem( /** * One typeshed warning row ([LSPCFGED-TYPESHED-SERVICE-INFO]). * - * A warning's message names its own fix (`UNPINNED` names **Pin current**), and - * every one of those fixes lives in the Configuration Editor — so the row - * carries a single navigation-only command that opens it. This is the ONE + * A warning's message names its own fix (`NO SOURCE` names **Download + * pinned**), and every one of those fixes lives in the Configuration Editor — + * so the row carries a single navigation-only command that opens it. This is the ONE * documented exception to [EXTACT-INFO-AFFORDANCE]'s "read-only rows have no * command": it navigates, it never mutates configuration. * @@ -292,6 +293,27 @@ function typeshedWarningItem( return item; } +/** + * The persistent NO SOURCE row ([LSPCFGED-TYPESHED-SERVICE-INFO]): the pinned + * commit is absent from this machine (or failed verification), analysis does + * not run, and no substitute source is used. Its message names its fix — + * **Download pinned** — which lives in the Configuration Editor, so the row + * navigates there exactly like a warning row. + */ +function typeshedNoSourceItems( + prefix: string, + status: TypeshedStatusState, + editorSupported: boolean, +): InfoTextItem[] { + if (status.lifecycle.kind !== "NoSource") { return []; } + const reason = status.noSourceReason ?? "The pinned commit is not available on this machine"; + return [typeshedWarningItem(prefix, { + code: "NO SOURCE", + message: `${reason} — use Download pinned to restore it`, + severity: { kind: "High" }, + }, editorSupported)]; +} + function typeshedInfoItems( statuses: ReadonlyMap, editorSupported: boolean, @@ -306,9 +328,7 @@ function typeshedInfoItems( typeshedLifecycleIcon(status), ), typeshedSourceItem(prefix, rootUri, status), - ...(status.blockedReason === undefined - ? [] - : [new InfoTextItem(`${prefix} Blocked`, status.blockedReason, "error")]), + ...typeshedNoSourceItems(prefix, status, editorSupported), ...status.warnings.map((warning) => typeshedWarningItem(prefix, warning, editorSupported)), ]; return rows; diff --git a/vscode-extension/src/store-types.ts b/vscode-extension/src/store-types.ts index 5e87085f..187872d5 100644 --- a/vscode-extension/src/store-types.ts +++ b/vscode-extension/src/store-types.ts @@ -1,3 +1,4 @@ +// Implements [VSIX-ARCHITECTURE]. See docs/specs/VSIX-SPEC.md#VSIX-ARCHITECTURE /** Public and mutable backing types for the centralized extension store. */ import type { ReadonlySignal, Signal } from "@preact/signals-core"; diff --git a/vscode-extension/src/test/runTest.ts b/vscode-extension/src/test/runTest.ts index ed40242d..e904e3da 100644 --- a/vscode-extension/src/test/runTest.ts +++ b/vscode-extension/src/test/runTest.ts @@ -140,6 +140,14 @@ async function main(): Promise { // bundled VSIX path. A bare temp workspace silently disarms every // diagnostics assertion (no config → house rules off → zero // diagnostics → timeouts). + // + // That settings file deliberately carries NO `basilisk.enabled` key. + // `enabled` defaults to `true`, and `type-checking-toggle.test.ts` + // restores it with `cfg.update('enabled', original)` — VS Code DELETES + // a workspace setting written back to its default rather than writing + // it out, so a committed `"basilisk.enabled": true` is stripped by + // every full run and leaves the tree dirty. Absent is the stable + // state, and it means exactly the same thing. const workspace = path.join(extensionDevelopmentPath, 'test-fixtures', 'workspace'); await runTests({ diff --git a/vscode-extension/src/test/suite/configuration-editor-focus.test.ts b/vscode-extension/src/test/suite/configuration-editor-focus.test.ts index 15775c58..80646113 100644 --- a/vscode-extension/src/test/suite/configuration-editor-focus.test.ts +++ b/vscode-extension/src/test/suite/configuration-editor-focus.test.ts @@ -58,7 +58,7 @@ function snapshotWithRule(): ConfigurationSnapshot { disabledRules: 0, }, problems: [], - typeshed: typeshedFixture({ acquiring: true }), + typeshed: typeshedFixture({ downloading: true }), }; } diff --git a/vscode-extension/src/test/suite/configuration-editor-typeshed-dom.test.ts b/vscode-extension/src/test/suite/configuration-editor-typeshed-dom.test.ts index 50b47eb2..3136f2b8 100644 --- a/vscode-extension/src/test/suite/configuration-editor-typeshed-dom.test.ts +++ b/vscode-extension/src/test/suite/configuration-editor-typeshed-dom.test.ts @@ -1,13 +1,16 @@ -// Tests [LSPCFGED-TYPESHED] in a REAL webview DOM, driven like a user. +// Tests [LSPCFGED-TYPESHED] / [LSPCFGED-TYPESHED-DOWNLOAD] in a REAL webview +// DOM, driven like a user. // // The reported failures this suite locks down: -// * the source selector showed "Latest" while a commit was pinned, because -// an unapplied choice was never reverted — selecting Latest must CLEAR the -// pin, and every control must render the configuration that actually holds; -// * "Reuse downloads" did nothing: the toggle sat behind an impact dialog -// built for rule severities, so a dismissed dialog left the box flipped -// with nothing written; -// * controls for sources that were not selected were rendered at all. +// * clicking a source radio flashed a full-panel spinner screen and locked +// every control — the deleted lock screen must STAY deleted: no overlay +// node, no inert shell, no transient disabled state, ever; +// * a "Latest" source radio was rendered although no such source exists — +// exactly two sources may ever appear; +// * a running download must show progress ON the button that started it +// while every other control stays live and editable; +// * a missing source must surface as a persistent inline row carrying its +// own fix (Download pinned), never as a blocking state. // // Each test is one continuous user journey: every interaction is followed by a // full DOM probe, and every probe is asserted. @@ -20,10 +23,12 @@ import { ScenarioHost, type DomStep, } from "./webview-dom-harness"; -import { ACTIVE_COMMIT, OTHER_COMMIT } from "./typeshed-fixture"; +import { ACTIVE_COMMIT, LATEST_COMMIT, OTHER_COMMIT } from "./typeshed-fixture"; +import { decodeConfigurationEditorIntent } from "../../configuration-editor-intents"; const CUSTOM_FOLDER = "/workspace/vendor/typeshed"; -const CACHE_FOLDER = "/workspace/.cache/typeshed"; +const STORE_FOLDER = "/workspace/.basilisk/typeshed-store"; +const NO_SOURCE_REASON = "Pinned commit 1f2e3d4c is not in the local store"; interface Source { readonly mode: string; @@ -32,23 +37,32 @@ interface Source { readonly hint: string; } +interface Action { + readonly action: string; + readonly disabled: boolean; + readonly busy: boolean; +} + function step(steps: DomStep[] | undefined, label: string): DomStep { const found = (steps ?? []).find((entry) => entry.label === label); assert.ok(found, `driver never recorded step "${label}" (recorded: ${(steps ?? []).map((entry) => entry.label).join(", ")})`); return found; } -function source(entry: DomStep, mode: string): Source { - const sources = entry.sources as Source[]; - const found = sources.find((candidate) => candidate.mode === mode); - assert.ok(found, `step "${entry.label}" rendered no ${mode} source choice`); +function action(entry: DomStep, name: string): Action { + const found = (entry.actions as Action[]).find((candidate) => candidate.action === name); + assert.ok(found, `step "${entry.label}" rendered no ${name} button`); return found; } -/** Exactly one source is selected, and it is the expected one. */ +/** Exactly the two sources exist — a "Latest" radio may NEVER render. */ function assertSelected(entry: DomStep, mode: string): void { const sources = entry.sources as Source[]; - assert.strictEqual(sources.length, 3, `step "${entry.label}" must offer the three sources`); + assert.deepStrictEqual( + sources.map((candidate) => candidate.mode), + ["ExactCommit", "CustomFolder"], + `step "${entry.label}" must offer exactly the two sources — no Latest, ever`, + ); assert.deepStrictEqual( sources.filter((candidate) => candidate.checked).map((candidate) => candidate.mode), [mode], @@ -56,6 +70,24 @@ function assertSelected(entry: DomStep, mode: string): void { ); } +/** The deleted lock screen stays deleted and nothing source-shaped is disabled. */ +function assertNothingLocked(entry: DomStep): void { + assert.strictEqual(entry.overlayPresent, false, `step "${entry.label}" must render no full-panel overlay node`); + assert.strictEqual(entry.shellInert, false, `step "${entry.label}" must never make the shell inert`); + (entry.sources as Source[]).forEach((candidate) => { + assert.strictEqual(candidate.disabled, false, `step "${entry.label}" must keep the ${candidate.mode} radio enabled`); + }); + if (entry.commitPresent === true) { + assert.strictEqual(entry.commitDisabled, false, `step "${entry.label}" must keep the SHA field editable`); + } + if (entry.pickFolderDisabled !== null) { + assert.strictEqual(entry.pickFolderDisabled, false, `step "${entry.label}" must keep the custom folder picker live`); + } + if (entry.storePickerDisabled !== null) { + assert.strictEqual(entry.storePickerDisabled, false, `step "${entry.label}" must keep the store folder picker live`); + } +} + function mutationsOf(intents: readonly Record[], index: number): unknown { return intents[index]?.mutations; } @@ -66,25 +98,24 @@ const sourceJourneyDriver = String.raw` try { if (!await waitFor('[data-typeshed-source]')) { report({ ok: false, reason: 'typeshed controls never rendered' }); return; } await click(document.querySelector('[data-section-target="project"]')); - record('latest'); - // 1. Pin the active commit by choosing the pinned source. - await chooseSource('ExactCommit'); record('pinned'); - // 2. Reject an invalid SHA in place — nothing may be written. + // 1. Reject an invalid SHA in place — nothing may be written. await change(el('[data-typeshed-commit]'), 'not-a-sha'); record('invalid-sha'); - // 3. A valid SHA repins. + // 2. A valid SHA repins, atomically clearing any folder. await change(el('[data-typeshed-commit]'), '${OTHER_COMMIT}'); record('repinned'); - // 4. Back to Latest: the pin must be CLEARED. - await chooseSource('Latest'); - record('unpinned'); - // 5. A custom folder replaces the downloaded source entirely. - await chooseSource('CustomFolder'); + // 3. Choose the custom folder. The probe directly after the click — no + // settle — must find nothing disabled and no overlay: a radio change + // may never enter a transient locked state. + el('[data-typeshed-source="CustomFolder"]').click(); + record('custom-sync'); + await sleep(settleDelay); record('custom'); - // 6. Back to Latest, then cancel the folder picker: nothing changes. - await chooseSource('Latest'); - record('latest-again'); + // 4. Back to the pinned source: one mutation clears the folder. + await chooseSource('ExactCommit'); + record('repinned-from-custom'); + // 5. Custom again, but cancel the folder picker: nothing changes. await chooseSource('CustomFolder'); record('picker-cancelled'); report({ ok: true, steps }); @@ -92,48 +123,84 @@ const sourceJourneyDriver = String.raw` })(); `; -const downloadsDriver = String.raw` +const downloadLatestDriver = String.raw` + (async () => { + ${DRIVER_PRELUDE} + try { + if (!await waitFor('[data-typeshed-action="DownloadLatest"]')) { report({ ok: false, reason: 'download button never rendered' }); return; } + await click(document.querySelector('[data-section-target="project"]')); + record('ready'); + // The probe directly after the click — before the server's Downloading + // state lands — must show the spinner on THIS button and nothing else + // touched. + el('[data-typeshed-action="DownloadLatest"]').click(); + record('clicked-sync'); + await sleep(settleDelay); + record('downloading'); + // Nothing is blocked mid-download: an SHA edit still writes. + await change(el('[data-typeshed-commit]'), '${OTHER_COMMIT}'); + record('edited-mid-download'); + window.__realApi.postMessage({ type: 'domTestSettle' }); + await sleep(250); + record('settled'); + report({ ok: true, steps }); + } catch (error) { report({ ok: false, reason: String(error), steps }); } + })(); +`; + +const noSourceDriver = String.raw` + (async () => { + ${DRIVER_PRELUDE} + try { + if (!await waitFor('.typeshed-no-source')) { report({ ok: false, reason: 'the NO SOURCE row never rendered' }); return; } + await click(document.querySelector('[data-section-target="project"]')); + record('no-source'); + el('[data-typeshed-action="DownloadPinned"]').click(); + record('pinned-clicked-sync'); + await sleep(settleDelay); + record('pinned-downloading'); + window.__realApi.postMessage({ type: 'domTestSettle' }); + await sleep(250); + record('resolved'); + report({ ok: true, steps }); + } catch (error) { report({ ok: false, reason: String(error), steps }); } + })(); +`; + +const advancedDriver = String.raw` (async () => { ${DRIVER_PRELUDE} try { - if (!await waitFor('[data-typeshed-boolean="TypeshedCache"]')) { report({ ok: false, reason: 'download controls never rendered' }); return; } + if (!await waitFor('.typeshed-advanced')) { report({ ok: false, reason: 'advanced settings never rendered' }); return; } await click(document.querySelector('[data-section-target="project"]')); record('initial'); - await change(el('[data-typeshed-boolean="TypeshedCache"]'), false); - record('reuse-off'); - await change(el('[data-typeshed-boolean="TypeshedCache"]'), true); - record('reuse-on'); - await change(el('[data-typeshed-boolean="TypeshedVerify"]'), false); - record('verify-off'); - // Advanced settings stay folded away until asked for. - const foldedByDefault = !el('.typeshed-advanced').open; el('.typeshed-advanced').open = true; el('.typeshed-advanced').dispatchEvent(new Event('toggle')); - await change(el('[data-typeshed-text="TypeshedUrl"]'), 'https://mirror.test/{sha}.zip'); - record('mirror-set'); - await change(el('[data-typeshed-text="TypeshedUrl"]'), ''); - record('mirror-cleared'); - await click(el('[data-pick-typeshed-folder="TypeshedCachePath"]')); - record('cache-folder'); - report({ ok: true, steps, advancedFoldedByDefault: foldedByDefault }); + await click(el('[data-pick-typeshed-folder="TypeshedStorePath"]')); + record('store-picked'); + report({ ok: true, steps }); } catch (error) { report({ ok: false, reason: String(error), steps }); } })(); `; -const acquiringDriver = String.raw` +const unpinDriver = String.raw` (async () => { ${DRIVER_PRELUDE} try { - if (!await waitFor('[data-typeshed-source]')) { report({ ok: false, reason: 'typeshed controls never rendered' }); return; } + if (!await waitFor('[data-typeshed-commit]')) { report({ ok: false, reason: 'commit field never rendered' }); return; } await click(document.querySelector('[data-section-target="project"]')); - record('acquiring'); - // Every control is inert, so no second mutation can race the candidate. - await chooseSource('Latest'); - await chooseSource('CustomFolder'); - record('acquiring-after-clicks'); - window.__realApi.postMessage({ type: 'domTestSettle' }); - await sleep(250); - record('settled'); + record('pinned'); + // Emptying the SHA is how a user unpins from the field itself. Focus + // the field first: the snapshot re-render must not eat that focus. + el('[data-typeshed-commit]').focus(); + await change(el('[data-typeshed-commit]'), ' '); + const unpinned = record('unpinned'); + unpinned.commitFocused = document.activeElement !== null + && document.activeElement.dataset !== undefined + && document.activeElement.dataset.typeshedCommit === 'TypeshedCommit'; + // The license action reaches the server verbatim and spins nothing. + await click(el('[data-typeshed-action="ViewLicense"]')); + record('after-license'); report({ ok: true, steps }); } catch (error) { report({ ok: false, reason: String(error), steps }); } })(); @@ -181,58 +248,25 @@ const dialogDriver = String.raw` })(); `; - -const pinnedStartDriver = String.raw` - (async () => { - ${DRIVER_PRELUDE} - try { - if (!await waitFor('[data-typeshed-commit]')) { report({ ok: false, reason: 'commit field never rendered' }); return; } - await click(document.querySelector('[data-section-target="project"]')); - record('pinned'); - // Emptying the SHA is how a user unpins from the field itself. - await change(el('[data-typeshed-commit]'), ' '); - record('unpinned'); - // The two remaining actions reach the server verbatim. - await click(el('[data-typeshed-action="AcquireFresh"]')); - await click(el('[data-typeshed-action="ViewLicense"]')); - record('after-actions'); - report({ ok: true, steps }); - } catch (error) { report({ ok: false, reason: String(error), steps }); } - })(); -`; - -/** 0-1: Latest offers no source-specific field; pinning writes the ACTIVE commit. */ -function assertLatestThenPinned(steps: DomStep[] | undefined, intents: readonly Record[]): void { - const latest = step(steps, "latest"); - assertSelected(latest, "Latest"); - assert.strictEqual(latest.commitPresent, false, "Latest has no commit field to mislead with"); - assert.strictEqual(latest.pathPresent, false, "Latest has no folder field"); - assert.strictEqual(latest.reusePresent, true, "a downloaded source states its download policy"); - assert.strictEqual(latest.advancedPresent, true); - assert.strictEqual(source(latest, "ExactCommit").disabled, false, "an active commit can be pinned"); - assert.deepStrictEqual( - (latest.actions as { action: string }[]).map((action) => action.action), - ["AcquireFresh", "ViewLicense"], - "pinning is the source choice itself, not a redundant button", - ); - assert.strictEqual((latest.status as Record).State, "Ready"); - - assert.strictEqual(intents[0]?.type, "ready"); - assert.deepStrictEqual( - { type: intents[1]?.type, action: intents[1]?.action }, - { type: "typeshedAction", action: "PinCurrent" }, - "choosing the pinned source pins the active commit", - ); +/** 0-2: the pinned default, in-place SHA rejection, and the atomic repin. */ +function assertPinnedAndCommitEditing(steps: DomStep[] | undefined, intents: readonly Record[]): void { const pinned = step(steps, "pinned"); assertSelected(pinned, "ExactCommit"); - assert.strictEqual(pinned.commitPresent, true); + assertNothingLocked(pinned); + assert.strictEqual(pinned.commitPresent, true, "the pinned source renders its SHA field"); assert.strictEqual(pinned.commitValue, ACTIVE_COMMIT); assert.strictEqual(pinned.pathPresent, false, "a pin and a folder can never coexist"); - assert.strictEqual(pinned.reusePresent, true, "a pinned commit is still downloaded"); -} + assert.strictEqual(pinned.booleanControls, 0, "the cache/verify toggles are deleted"); + assert.strictEqual(pinned.textControls, 0, "the alternate-URL text control is deleted"); + assert.strictEqual(pinned.advancedPresent, true, "the store folder lives under Advanced"); + assert.deepStrictEqual( + (pinned.actions as Action[]).map((entry) => entry.action), + ["DownloadLatest", "ViewLicense"], + "Download latest is always offered; Download pinned only without a source", + ); + assert.strictEqual((pinned.status as Record).State, "Ready"); + assert.strictEqual(intents[0]?.type, "ready"); -/** 2-4: an invalid SHA is refused in place; Latest CLEARS the pin. */ -function assertCommitEditing(steps: DomStep[] | undefined, intents: readonly Record[]): void { const invalid = step(steps, "invalid-sha"); assert.strictEqual(invalid.commitInvalid, "true", "the field must report itself invalid"); assert.ok( @@ -249,210 +283,248 @@ function assertCommitEditing(steps: DomStep[] | undefined, intents: readonly Rec assert.strictEqual(repinned.commitValue, OTHER_COMMIT); assert.strictEqual(repinned.commitError, null, "the error must clear once the SHA is valid"); assert.strictEqual(repinned.commitInvalid, null); - assert.deepStrictEqual(mutationsOf(intents, 2), [ - { kind: "SetTypeshedSetting", key: { kind: "TypeshedCommit" }, value: { kind: "Text", value: OTHER_COMMIT } }, + assert.strictEqual(repinned.dialogOpen, false, "a Typeshed edit never opens the impact dialog"); + assert.deepStrictEqual(mutationsOf(intents, 1), [ + { kind: "SetTypeshedSetting", key: { kind: "TypeshedCommit" }, value: OTHER_COMMIT }, { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPath" } }, ]); +} - assert.deepStrictEqual(mutationsOf(intents, 3), [ - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedCommit" } }, - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPath" } }, - ], "selecting Latest must CLEAR the pinned commit"); - const unpinned = step(steps, "unpinned"); - assertSelected(unpinned, "Latest"); - assert.strictEqual(unpinned.commitPresent, false, "an unpinned source keeps no commit field"); - assert.strictEqual(unpinned.dialogOpen, false, "a source switch is not an impact trade-off"); +/** + * Every intent the webview posts must survive the decoder the extension host + * actually runs it through ([LSPCFGED-TYPESHED]). + * + * The reported failure this locks down: the webview built + * `SetTypeshedSetting` with a retired `{ kind: 'Text', value }` wrapper. Both + * sides were tested in isolation and both passed, but the decoder dropped the + * unknown shape, so editing a typeshed path or commit in the editor silently + * did nothing. Asserting the DOM shape alone cannot catch that — the posted + * intent has to be decoded. + */ +function assertEveryPostedIntentDecodes(intents: readonly Record[]): void { + const actionable = intents.filter((intent) => intent.type !== "ready" && intent.type !== "domTestBoot"); + assert.ok(actionable.length > 0, "the journey must post at least one actionable intent"); + for (const intent of actionable) { + assert.notStrictEqual( + decodeConfigurationEditorIntent(intent), + undefined, + `the extension host must be able to decode what the webview posted: ${JSON.stringify(intent)}`, + ); + } } -/** 5-6: a custom folder replaces the download policy; a cancelled pick changes nothing. */ +/** 3-5: the folder source, the atomic switch back, and the cancelled picker. */ function assertCustomFolder(steps: DomStep[] | undefined, intents: readonly Record[]): void { + // The probe straight after the radio click: no overlay, no disabled + // control, no dialog — the reported spinner-lock cannot come back. + const customSync = step(steps, "custom-sync"); + assertNothingLocked(customSync); + assert.strictEqual(customSync.dialogOpen, false, "a source switch is not an impact trade-off"); + assert.deepStrictEqual( - { type: intents[4]?.type, key: intents[4]?.key }, + { type: intents[2]?.type, key: intents[2]?.key }, { type: "pickTypeshedFolder", key: "TypeshedPath" }, ); const custom = step(steps, "custom"); assertSelected(custom, "CustomFolder"); + assertNothingLocked(custom); assert.strictEqual(custom.pathValue, CUSTOM_FOLDER); - assert.strictEqual(custom.commitPresent, false); - assert.strictEqual(custom.reusePresent, false, "a user-managed folder downloads nothing"); - assert.strictEqual(custom.verifyPresent, false); - assert.strictEqual(custom.advancedPresent, false); - const customPin = source(custom, "ExactCommit"); - assert.strictEqual(customPin.disabled, true, "a custom folder has no upstream commit to pin"); - assert.ok( - customPin.hint.includes("Choose Latest first"), - `an unavailable source must teach why (got "${customPin.hint}")`, - ); + assert.strictEqual(custom.commitPresent, false, "only the ACTIVE source's field exists"); + assert.strictEqual(custom.advancedPresent, false, "a user-managed folder has no store folder"); + assert.strictEqual(action(custom, "ViewLicense").disabled, true, "a custom folder supplies no license document"); + assert.strictEqual(action(custom, "DownloadLatest").disabled, false, "Download latest stays offered"); - const latestAgain = step(steps, "latest-again"); - assertSelected(latestAgain, "Latest"); - assert.strictEqual(latestAgain.pathPresent, false); - assert.strictEqual(latestAgain.reusePresent, true); + assert.deepStrictEqual(mutationsOf(intents, 3), [ + { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPath" } }, + ], "returning to the pinned source clears exactly the folder"); + const repinned = step(steps, "repinned-from-custom"); + assertSelected(repinned, "ExactCommit"); + assert.strictEqual( + repinned.commitValue, + ACTIVE_COMMIT, + "the folder pick cleared the pin, so the bundled commit serves again", + ); + assert.strictEqual(repinned.pathPresent, false); + assert.deepStrictEqual( + { type: intents[4]?.type, key: intents[4]?.key }, + { type: "pickTypeshedFolder", key: "TypeshedPath" }, + ); const cancelled = step(steps, "picker-cancelled"); - assertSelected(cancelled, "Latest"); + assertSelected(cancelled, "ExactCommit"); assert.strictEqual(cancelled.pathPresent, false, "a cancelled picker must not select the folder source"); - assert.strictEqual(cancelled.reusePresent, true); } -/** Toggling a download setting writes at once and survives the re-render. */ -function assertDownloadToggles(steps: DomStep[] | undefined, intents: readonly Record[]): void { - const initial = step(steps, "initial"); - assert.strictEqual(initial.reuseChecked, true, "the default policy reuses gate-accepted downloads"); - assert.strictEqual(initial.verifyChecked, true); - assert.strictEqual(initial.urlValue, "", "no mirror is configured by default"); - - const off = step(steps, "reuse-off"); - assert.deepStrictEqual(mutationsOf(intents, 1), [ - { kind: "SetTypeshedSetting", key: { kind: "TypeshedCache" }, value: { kind: "Boolean", value: false } }, - ]); - assert.strictEqual(off.reuseChecked, false, "the re-rendered checkbox must hold the written value"); - assert.strictEqual(off.dialogOpen, false, "a Typeshed toggle never opens the impact dialog"); - assert.strictEqual(off.verifyChecked, true, "one toggle changes one setting"); +/** A running Download latest: spinner on that button only, everything else live. */ +function assertDownloadLatest(steps: DomStep[] | undefined, intents: readonly Record[]): void { + const ready = step(steps, "ready"); + assertNothingLocked(ready); + assert.deepStrictEqual( + (ready.actions as Action[]).map((entry) => [entry.action, entry.disabled, entry.busy]), + [["DownloadLatest", false, false], ["ViewLicense", false, false]], + "Ready offers Download latest live and no Download pinned", + ); - const on = step(steps, "reuse-on"); + assert.deepStrictEqual( + { type: intents[1]?.type, action: intents[1]?.action }, + { type: "typeshedAction", action: "DownloadLatest" }, + ); + const clicked = step(steps, "clicked-sync"); + assertNothingLocked(clicked); + const clickedButton = action(clicked, "DownloadLatest"); + assert.strictEqual(clickedButton.busy, true, "the invoking button goes busy at once"); + assert.strictEqual(clickedButton.disabled, true, "a second identical download cannot start"); + + const downloading = step(steps, "downloading"); + assert.strictEqual((downloading.status as Record).State, "Downloading"); + assertSelected(downloading, "ExactCommit"); + assertNothingLocked(downloading); + assert.strictEqual(action(downloading, "DownloadLatest").busy, true, "the spinner stays on the invoking button"); + assert.strictEqual(downloading.noSourcePresent, false, "a latest download is not a NO SOURCE state"); + + const edited = step(steps, "edited-mid-download"); assert.deepStrictEqual(mutationsOf(intents, 2), [ - { kind: "SetTypeshedSetting", key: { kind: "TypeshedCache" }, value: { kind: "Boolean", value: true } }, - ]); - assert.strictEqual(on.reuseChecked, true, "toggling back must round-trip"); - - const verifyOff = step(steps, "verify-off"); - assert.deepStrictEqual(mutationsOf(intents, 3), [ - { kind: "SetTypeshedSetting", key: { kind: "TypeshedVerify" }, value: { kind: "Boolean", value: false } }, - ]); - assert.strictEqual(verifyOff.verifyChecked, false); - assert.strictEqual(verifyOff.reuseChecked, true, "the other toggle is untouched"); -} - -/** The rarely-needed mirror and cache folder live behind Advanced. */ -function assertAdvancedDownloads(steps: DomStep[] | undefined, intents: readonly Record[]): void { - const mirror = step(steps, "mirror-set"); - assert.deepStrictEqual(mutationsOf(intents, 4), [ - { kind: "SetTypeshedSetting", key: { kind: "TypeshedUrl" }, value: { kind: "Text", value: "https://mirror.test/{sha}.zip" } }, - ]); - assert.strictEqual(mirror.urlValue, "https://mirror.test/{sha}.zip"); - assert.strictEqual(mirror.advancedOpen, true, "the disclosure must not snap shut under the user's hands"); - - const cleared = step(steps, "mirror-cleared"); - assert.deepStrictEqual(mutationsOf(intents, 5), [ - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedUrl" } }, - ], "emptying a text setting removes the entry rather than writing an empty one"); - assert.strictEqual(cleared.urlValue, ""); + { kind: "SetTypeshedSetting", key: { kind: "TypeshedCommit" }, value: OTHER_COMMIT }, + { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPath" } }, + ], "an SHA edit mid-download still writes — configuration never waits on the network"); + assert.strictEqual(edited.commitValue, OTHER_COMMIT); + assertNothingLocked(edited); - const cacheFolder = step(steps, "cache-folder"); + const settled = step(steps, "settled"); + assert.strictEqual((settled.status as Record).State, "Ready"); + assert.strictEqual(settled.commitValue, LATEST_COMMIT, "the finished download wrote the resolved SHA"); assert.deepStrictEqual( - { type: intents[6]?.type, key: intents[6]?.key }, - { type: "pickTypeshedFolder", key: "TypeshedCachePath" }, + (settled.actions as Action[]).map((entry) => [entry.action, entry.disabled, entry.busy]), + [["DownloadLatest", false, false], ["ViewLicense", false, false]], + "settling releases the button and removes the spinner", ); - assert.strictEqual(cacheFolder.cacheFolderValue, CACHE_FOLDER); - assert.strictEqual(cacheFolder.reuseChecked, true, "unrelated policy survives a folder pick"); - assert.strictEqual(cacheFolder.verifyChecked, false); } -/** Unpinning from the field itself, and the two verbatim server actions. */ -function assertUnpinAndActions(steps: DomStep[] | undefined, intents: readonly Record[]): void { - const pinned = step(steps, "pinned"); - assertSelected(pinned, "ExactCommit"); - assert.strictEqual(pinned.commitValue, OTHER_COMMIT, "the field shows the configured pin"); - assert.strictEqual(pinned.reusePresent, true, "a pinned commit still downloads"); - assert.strictEqual( - (pinned.status as Record)["Active source"], - "Bundled · 83c2518a9e6a", - "the status states the ACTIVE source, which may differ from the configured one", +/** NO SOURCE: a persistent inline row whose fix is the Download pinned button. */ +function assertNoSource(steps: DomStep[] | undefined, intents: readonly Record[]): void { + const noSource = step(steps, "no-source"); + assert.strictEqual((noSource.status as Record).State, "NoSource"); + assert.strictEqual(noSource.noSourcePresent, true, "the reason renders as a persistent row in the panel"); + assert.ok( + String(noSource.noSourceText).includes(NO_SOURCE_REASON), + `the row must state the server's reason (got "${String(noSource.noSourceText)}")`, ); - - assert.deepStrictEqual(mutationsOf(intents, 1), [ - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedCommit" } }, - ], "clearing the field removes the entry, it never writes an empty SHA"); - const unpinned = step(steps, "unpinned"); - assertSelected(unpinned, "Latest"); - assert.strictEqual(unpinned.commitPresent, false); - assert.strictEqual(unpinned.reuseChecked, true, "download policy survives the source change"); + assert.ok(String(noSource.noSourceText).includes("Download pinned"), "the row carries its fix inline"); + assertSelected(noSource, "ExactCommit"); + assertNothingLocked(noSource); + assert.strictEqual(noSource.commitValue, OTHER_COMMIT, "the pinned SHA stays visible and editable"); + assert.strictEqual(action(noSource, "DownloadPinned").disabled, false); assert.deepStrictEqual( - intents.slice(2).map((intent) => [intent.type, intent.action]), - [["typeshedAction", "AcquireFresh"], ["typeshedAction", "ViewLicense"]], - "both actions are relayed verbatim; the client executes neither", + { type: intents[1]?.type, action: intents[1]?.action }, + { type: "typeshedAction", action: "DownloadPinned" }, + ); + assert.ok( + !intents.some((intent) => intent.type === "preview"), + "a download writes no configuration at all", + ); + const clicked = step(steps, "pinned-clicked-sync"); + assertNothingLocked(clicked); + assert.strictEqual(action(clicked, "DownloadPinned").busy, true, "the invoking button goes busy at once"); + assert.strictEqual(action(clicked, "DownloadLatest").busy, false, "the other download button never spins"); + + const downloading = step(steps, "pinned-downloading"); + assert.strictEqual((downloading.status as Record).State, "Downloading"); + assert.strictEqual(downloading.noSourcePresent, true, "the row keeps the busy button until the source settles"); + assert.strictEqual(action(downloading, "DownloadPinned").busy, true); + assert.strictEqual(action(downloading, "DownloadLatest").busy, false); + assert.strictEqual(action(downloading, "DownloadLatest").disabled, true, "one download at a time"); + assertNothingLocked(downloading); + + const resolved = step(steps, "resolved"); + assert.strictEqual((resolved.status as Record).State, "Ready"); + assert.strictEqual(resolved.noSourcePresent, false, "the row disappears once a source exists"); + assert.deepStrictEqual( + (resolved.actions as Action[]).map((entry) => entry.action), + ["DownloadLatest", "ViewLicense"], + "Download pinned is offered only while there is no source", ); - const after = step(steps, "after-actions"); - assertSelected(after, "Latest"); - assert.strictEqual(after.dialogOpen, false, "an action never opens the impact dialog"); } suite("Configuration editor — Typeshed source in a real webview DOM", () => { - test("switching sources writes exactly one source and clears the others", async function () { + test("switching between the two sources writes one atomic mutation and never locks the panel", async function () { this.timeout(RESULT_TIMEOUT_MS + 20_000); const host = new ScenarioHost({ folders: [CUSTOM_FOLDER, undefined] }); const { result, intents } = await runScenario(sourceJourneyDriver, host); assert.strictEqual(result.ok, true, `driver failed: ${result.reason ?? "unknown"}`); - assertLatestThenPinned(result.steps, intents); - assertCommitEditing(result.steps, intents); + assertPinnedAndCommitEditing(result.steps, intents); assertCustomFolder(result.steps, intents); + assertEveryPostedIntentDecodes(intents); }); - test("download policy toggles write immediately and re-render from the configuration", async function () { + test("Download latest spins only its own button while every control stays live", async function () { this.timeout(RESULT_TIMEOUT_MS + 20_000); - const host = new ScenarioHost({ folders: [CACHE_FOLDER] }); - const { result, intents } = await runScenario(downloadsDriver, host); + const host = new ScenarioHost(); + const { result, intents } = await runScenario(downloadLatestDriver, host); assert.strictEqual(result.ok, true, `driver failed: ${result.reason ?? "unknown"}`); - assert.strictEqual(result.advancedFoldedByDefault, true, "advanced settings start folded away"); - assertDownloadToggles(result.steps, intents); - assertAdvancedDownloads(result.steps, intents); + assertDownloadLatest(result.steps, intents); + assertEveryPostedIntentDecodes(intents); }); - - test("emptying the pinned SHA unpins, and the two actions reach the server", async function () { + test("NO SOURCE renders a persistent inline row fixed by Download pinned", async function () { this.timeout(RESULT_TIMEOUT_MS + 20_000); - const host = new ScenarioHost({ config: { commit: OTHER_COMMIT } }); - const { result, intents } = await runScenario(pinnedStartDriver, host); + const host = new ScenarioHost({ config: { commit: OTHER_COMMIT }, noSourceReason: NO_SOURCE_REASON }); + const { result, intents } = await runScenario(noSourceDriver, host); assert.strictEqual(result.ok, true, `driver failed: ${result.reason ?? "unknown"}`); - - assertUnpinAndActions(result.steps, intents); + assertNoSource(result.steps, intents); }); - test("an in-flight acquisition locks every control until it settles", async function () { + test("Advanced holds only the store folder picker and remembers its disclosure", async function () { this.timeout(RESULT_TIMEOUT_MS + 20_000); - const host = new ScenarioHost({ acquiring: true }); - const { result, intents } = await runScenario(acquiringDriver, host); + const host = new ScenarioHost({ folders: [STORE_FOLDER] }); + const { result, intents } = await runScenario(advancedDriver, host); assert.strictEqual(result.ok, true, `driver failed: ${result.reason ?? "unknown"}`); - const steps = result.steps; - const acquiring = step(steps, "acquiring"); - assertSelected(acquiring, "Latest"); - (acquiring.sources as Source[]).forEach((candidate) => { - if (candidate.checked) { return; } - assert.strictEqual(candidate.disabled, true, `${candidate.mode} must be locked while acquiring`); - assert.ok( - candidate.hint.includes("being acquired"), - `a locked source must say why (got "${candidate.hint}")`, - ); - }); - assert.strictEqual(acquiring.reuseDisabled, true, "download policy is locked while acquiring"); + const initial = step(result.steps, "initial"); + assert.strictEqual(initial.booleanControls, 0, "the cache/verify toggles are deleted"); + assert.strictEqual(initial.textControls, 0, "the alternate-URL text control is deleted"); + assert.strictEqual(initial.advancedOpen, false, "advanced settings start folded away"); + assertNothingLocked(initial); + assert.deepStrictEqual( - (acquiring.actions as { action: string; disabled: boolean }[]).map((action) => action.disabled), - [true, true], - "no action may race an in-flight candidate", + { type: intents[1]?.type, key: intents[1]?.key }, + { type: "pickTypeshedFolder", key: "TypeshedStorePath" }, ); - assert.strictEqual((acquiring.status as Record).State, "Acquiring"); + const picked = step(result.steps, "store-picked"); + assert.strictEqual(picked.storeFolderValue, STORE_FOLDER); + assert.strictEqual(picked.advancedOpen, true, "the disclosure must not snap shut under the user's hands"); + }); - assert.deepStrictEqual( - intents.filter((intent) => intent.type !== "ready" && intent.type !== "occurrences"), - [], - "a locked control must send nothing at all", + test("emptying the pinned SHA unpins without stealing focus, and ViewLicense relays verbatim", async function () { + this.timeout(RESULT_TIMEOUT_MS + 20_000); + const host = new ScenarioHost({ config: { commit: OTHER_COMMIT } }); + const { result, intents } = await runScenario(unpinDriver, host); + assert.strictEqual(result.ok, true, `driver failed: ${result.reason ?? "unknown"}`); + + const pinned = step(result.steps, "pinned"); + assertSelected(pinned, "ExactCommit"); + assert.strictEqual(pinned.commitValue, OTHER_COMMIT, "the field shows the configured pin"); + + assert.deepStrictEqual(mutationsOf(intents, 1), [ + { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedCommit" } }, + ], "clearing the field removes the entry, it never writes an empty SHA"); + const unpinned = step(result.steps, "unpinned"); + assertSelected(unpinned, "ExactCommit"); + assert.strictEqual(unpinned.commitValue, ACTIVE_COMMIT, "the bundled commit serves once unpinned"); + assert.strictEqual( + unpinned.commitFocused, + true, + "the snapshot re-render must hand focus back to the SHA field — no flicker, no lost caret", ); - const afterClicks = step(steps, "acquiring-after-clicks"); - assertSelected(afterClicks, "Latest"); - - const settled = step(steps, "settled"); - assert.strictEqual((settled.status as Record).State, "Ready"); - (settled.sources as Source[]).forEach((candidate) => { - assert.strictEqual(candidate.disabled, false, `${candidate.mode} must unlock once settled`); - }); - assert.strictEqual(settled.reuseDisabled, false); + assert.deepStrictEqual( - (settled.actions as { disabled: boolean }[]).map((action) => action.disabled), - [false, false], + { type: intents[2]?.type, action: intents[2]?.action }, + { type: "typeshedAction", action: "ViewLicense" }, + "the license action is relayed verbatim; the client executes nothing", ); + const after = step(result.steps, "after-license"); + assert.strictEqual(after.dialogOpen, false, "an action never opens the impact dialog"); + assert.strictEqual(action(after, "ViewLicense").busy, false, "only downloads may spin a button"); }); test("a dismissed rule preview discards the change and restores the control", async function () { @@ -465,9 +537,11 @@ suite("Configuration editor — Typeshed source in a real webview DOM", () => { const before = step(steps, "before"); assert.strictEqual(before.ruleValue, "Error", "an untouched pep rule runs at error"); assert.strictEqual(before.dialogOpen, false); + assertNothingLocked(before); const opened = step(steps, "dialog-open"); assert.strictEqual(opened.dialogOpen, true, "a rule change still shows its exact impact"); + assert.strictEqual(opened.overlayPresent, false, "the impact dialog is the only modal surface"); assert.strictEqual( opened.ruleValue, "Error", diff --git a/vscode-extension/src/test/suite/configuration-editor-webview.test.ts b/vscode-extension/src/test/suite/configuration-editor-webview.test.ts index 7fb836c7..774e5743 100644 --- a/vscode-extension/src/test/suite/configuration-editor-webview.test.ts +++ b/vscode-extension/src/test/suite/configuration-editor-webview.test.ts @@ -26,23 +26,35 @@ suite("Configuration editor — untrusted intent decoder", () => { assert.strictEqual(decodeConfigurationEditorIntent({ type: "preview", mutations: [{ kind: "RemoveTag", tag: "basilisk" }], })?.type, "preview"); - for (const key of ["TypeshedPath", "TypeshedCommit", "TypeshedUrl", "TypeshedCachePath"]) { + // [LSPCFGED-TYPESHED]: the three surviving keys are all text-typed, so the + // model carries a bare String — `SetTypeshedSetting { key, value: String }` + // in models/configuration_editor.td. + for (const key of ["TypeshedPath", "TypeshedCommit", "TypeshedStorePath"]) { assert.strictEqual(decodeConfigurationEditorIntent({ type: "preview", - mutations: [{ kind: "SetTypeshedSetting", key: { kind: key }, value: { kind: "Text", value: "configured" } }], - })?.type, "preview"); - } - for (const key of ["TypeshedCache", "TypeshedVerify"]) { + mutations: [{ kind: "SetTypeshedSetting", key: { kind: key }, value: "configured" }], + })?.type, "preview", `SetTypeshedSetting ${key} must be accepted`); + // The retired tagged value shape must be REJECTED, not quietly coerced: + // accepting it would let the webview post a mutation the LSP cannot + // apply, losing the user's edit with no error. assert.strictEqual(decodeConfigurationEditorIntent({ type: "preview", - mutations: [{ kind: "SetTypeshedSetting", key: { kind: key }, value: { kind: "Boolean", value: false } }], - })?.type, "preview"); + mutations: [{ kind: "SetTypeshedSetting", key: { kind: key }, value: { kind: "Text", value: "configured" } }], + }), undefined, `the retired tagged value shape must be rejected for ${key}`); } assert.strictEqual(decodeConfigurationEditorIntent({ - type: "preview", mutations: [{ kind: "RemoveTypeshedSetting", key: { kind: "TypeshedUrl" } }], + type: "preview", mutations: [{ kind: "RemoveTypeshedSetting", key: { kind: "TypeshedStorePath" } }], })?.type, "preview"); - assert.strictEqual(decodeConfigurationEditorIntent({ type: "typeshedAction", action: "PinCurrent" })?.type, "typeshedAction"); - assert.strictEqual(decodeConfigurationEditorIntent({ type: "pickTypeshedFolder", key: "TypeshedPath" })?.type, "pickTypeshedFolder"); + for (const download of ["DownloadLatest", "DownloadPinned", "ViewLicense"]) { + assert.strictEqual( + decodeConfigurationEditorIntent({ type: "typeshedAction", action: download })?.type, + "typeshedAction", + `${download} is the complete action vocabulary`, + ); + } + for (const key of ["TypeshedPath", "TypeshedStorePath"]) { + assert.strictEqual(decodeConfigurationEditorIntent({ type: "pickTypeshedFolder", key })?.type, "pickTypeshedFolder"); + } }); // [CONFIGEDITOR-ACCEPTANCE]: selector mutations, Inherit/Native settings, @@ -67,14 +79,38 @@ suite("Configuration editor — untrusted intent decoder", () => { assert.strictEqual(decodeConfigurationEditorIntent({ type: "preview", mutations: [{ kind: "SetTag", tag: "", severity: { kind: "Error" } }], }), undefined); + // [LSPCFGED-TYPESHED]: the cache/verify toggles, the cache-path key, and + // the alternate-URL key are deleted from the contract entirely. + for (const key of ["TypeshedCache", "TypeshedVerify", "TypeshedCachePath", "TypeshedUrl", "ArbitraryKey"]) { + // The value is a VALID bare string, so the retired key is the only thing + // that can cause the rejection — a malformed value would let this pass + // even if the key check regressed. + assert.strictEqual(decodeConfigurationEditorIntent({ + type: "preview", + mutations: [{ kind: "SetTypeshedSetting", key: { kind: key }, value: "x" }], + }), undefined, `${key} was removed from the contract`); + assert.strictEqual(decodeConfigurationEditorIntent({ + type: "preview", + mutations: [{ kind: "RemoveTypeshedSetting", key: { kind: key } }], + }), undefined, `${key} must not be removable either`); + } + // A surviving key never accepts a boolean value. assert.strictEqual(decodeConfigurationEditorIntent({ type: "preview", - mutations: [{ kind: "SetTypeshedSetting", key: { kind: "TypeshedVerify" }, value: { kind: "Text", value: "false" } }], - }), undefined); - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "preview", - mutations: [{ kind: "SetTypeshedSetting", key: { kind: "ArbitraryKey" }, value: { kind: "Text", value: "x" } }], + mutations: [{ kind: "SetTypeshedSetting", key: { kind: "TypeshedCommit" }, value: { kind: "Boolean", value: true } }], }), undefined); + // The pin-current/acquire-fresh actions and the cache-path picker are gone. + for (const legacyAction of ["PinCurrent", "AcquireFresh"]) { + assert.strictEqual( + decodeConfigurationEditorIntent({ type: "typeshedAction", action: legacyAction }), + undefined, + `${legacyAction} was removed from the contract`, + ); + } + assert.strictEqual( + decodeConfigurationEditorIntent({ type: "pickTypeshedFolder", key: "TypeshedCachePath" }), + undefined, + ); }); // [CONFIGEDITOR-OPERATIONS]: occurrence reads use only the all/codes/tags @@ -103,7 +139,7 @@ suite("Configuration editor — untrusted intent decoder", () => { }); suite("Configuration editor — hardened, accessible document", () => { - test("is CSP locked, theme-native, zoom resilient, inert while blocked, and keyboard traversable", () => { + test("is CSP locked, theme-native, zoom resilient, never self-blocking, and keyboard traversable", () => { const html = buildConfigurationEditorDocument(); assert.ok(html.includes("default-src 'none'")); assert.ok(/style-src 'nonce-[^']+'/.test(html)); @@ -122,8 +158,13 @@ suite("Configuration editor — hardened, accessible document", () => { assert.ok(html.includes("vscode-high-contrast")); assert.ok(html.includes("prefers-reduced-motion")); assert.ok(html.includes('id="announcer" class="sr-only" aria-live="polite"')); - assert.ok(html.includes(".inert = blocking")); - assert.ok(html.includes('aria-modal="true"')); + // The full-panel lock screen is DELETED ([LSPCFGED-TYPESHED-DOWNLOAD]): + // no overlay node, no code that makes the shell inert, no modal state + // card — editor lifecycle renders as the non-blocking inline notice. + assert.ok(!html.includes("state-overlay"), "no full-panel overlay element may exist"); + assert.ok(!html.includes("inert"), "no code path may make the panel inert"); + assert.ok(!html.includes("aria-modal"), "the native impact dialog is the only modal surface"); + assert.ok(html.includes('id="state-notice" role="status"'), "lifecycle renders as an inline notice row"); assert.ok(html.includes("max-height: calc(100vh - 32px)")); assert.ok(html.includes("function moveVirtualRuleFocus(event)")); assert.ok(html.includes('id="rule-spacer" role="list"')); @@ -134,6 +175,26 @@ suite("Configuration editor — hardened, accessible document", () => { assert.ok(html.includes("viewport.scrollTop = target * ROW_HEIGHT")); }); + // [LSPCFGED-TYPESHED] / [LSPCFGED-TYPESHED-DOWNLOAD]: two sources and no + // third, download buttons instead of lifecycle locks, and none of the + // deleted cache/verify/URL controls. + test("ships the two-source Typeshed panel with download buttons and no deleted controls", () => { + const html = buildConfigurationEditorDocument(); + assert.ok(html.includes("'Pinned commit'"), "the pinned-commit radio exists"); + assert.ok(html.includes("'Custom folder'"), "the custom-folder radio exists"); + assert.ok(!html.includes("'Latest'"), "no Latest source radio may ever render"); + assert.ok(html.includes("'DownloadLatest', 'Download latest'"), "Download latest is a real button"); + assert.ok(html.includes("'DownloadPinned', 'Download pinned'"), "Download pinned is the NO SOURCE fix"); + assert.ok(html.includes("typeshed-no-source"), "the missing source renders as an inline row"); + assert.ok(!html.includes("PinCurrent"), "the PinCurrent action is deleted"); + assert.ok(!html.includes("AcquireFresh"), "the AcquireFresh action is deleted"); + assert.ok(!html.includes("TypeshedCache"), "the cache toggle and cache path are deleted"); + assert.ok(!html.includes("TypeshedVerify"), "the verify toggle is deleted"); + assert.ok(!html.includes("TypeshedUrl"), "the alternate-URL setting is deleted"); + assert.ok(html.includes("TypeshedStorePath"), "the store folder picker remains under Advanced"); + assert.ok(html.includes("COMMIT_PATTERN"), "the 40-hex SHA gate remains client-side"); + }); + // [CONFIGEDITOR-VSIX-EXPERIENCE]: the tag-first Rules view is the whole // editor. Tag groups get the tag-entry control; rows get per-rule entry // controls; pep controls have no Disabled option ([CHKARCH-CONFIG-MODEL]). diff --git a/vscode-extension/src/test/suite/configuration-editor.test.ts b/vscode-extension/src/test/suite/configuration-editor.test.ts index 19f95769..dcc9a5bd 100644 --- a/vscode-extension/src/test/suite/configuration-editor.test.ts +++ b/vscode-extension/src/test/suite/configuration-editor.test.ts @@ -272,8 +272,8 @@ suite("Configuration editor — typed mutation routing", () => { { kind: "RemoveTag", tag: "basilisk" }, ]; const typeshedMutations: EditorMutation[] = [ - { kind: "SetTypeshedSetting", key: { kind: "TypeshedCache" }, value: { kind: "Boolean", value: true } }, - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedUrl" } }, + { kind: "SetTypeshedSetting", key: { kind: "TypeshedStorePath" }, value: "/stores/typeshed" }, + { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedCommit" } }, ]; for (const mutation of [...ruleMutations, ...typeshedMutations]) { const store = createStore(); @@ -313,18 +313,18 @@ suite("Configuration editor — typed mutation routing", () => { } }); - test("routes Typeshed actions with the snapshot revision", async () => { + test("routes Typeshed download actions with the snapshot revision", async () => { const store = createStore(); const transport = new RecordingTransport(); const controller = new ConfigurationEditorController(store, transport); try { controller.open(ROOT_URI); await pollUntil(() => store.configurationEditor.value.phase === "ready"); - await controller.receive({ type: "typeshedAction", action: "AcquireFresh" }); + await controller.receive({ type: "typeshedAction", action: "DownloadPinned" }); assert.deepStrictEqual(transport.typeshedActionRequests, [{ rootUri: ROOT_URI, baseRevision: "revision-1", - action: { kind: "AcquireFresh" }, + action: { kind: "DownloadPinned" }, }]); assert.strictEqual(store.configurationEditor.value.snapshot?.revision, "revision-typeshed"); } finally { @@ -341,7 +341,7 @@ suite("Configuration editor — typed mutation routing", () => { try { controller.open(ROOT_URI); await pollUntil(() => store.configurationEditor.value.phase === "ready"); - const action = controller.receive({ type: "typeshedAction", action: "AcquireFresh" }); + const action = controller.receive({ type: "typeshedAction", action: "DownloadLatest" }); await pollUntil(() => transport.typeshedActionRequests.length === 1); transport.snapshotResult = configurationSnapshot("revision-newer"); await controller.receive({ type: "refresh" }); @@ -384,25 +384,39 @@ suite("Configuration editor — direct Typeshed writes and discarded previews", } }); - // Pinning is a source choice: the server's preview is applied at once, so - // the pinned source the user selected is the source they get. - test("PinCurrent applies the server's pin preview without a review step", async () => { + // A download is not a configuration edit ([LSPCFGED-TYPESHED-DOWNLOAD]): + // the action returns the refreshed snapshot at once (lifecycle Downloading) + // with no preview, no apply, and no review step — the editor stays fully + // interactive while the download runs. + test("DownloadLatest accepts the refreshed Downloading snapshot without preview or apply", async () => { const store = createStore(); const transport = new RecordingTransport(); - transport.typeshedActionResult = { kind: "Preview", preview: configurationPreview() }; + transport.typeshedActionResult = { + kind: "Snapshot", + snapshot: { + ...configurationSnapshot("revision-downloading"), + typeshed: typeshedFixture({ downloading: true }), + }, + }; const controller = new ConfigurationEditorController(store, transport); try { controller.open(ROOT_URI); await pollUntil(() => store.configurationEditor.value.phase === "ready"); - await controller.receive({ type: "typeshedAction", action: "PinCurrent" }); + await controller.receive({ type: "typeshedAction", action: "DownloadLatest" }); assert.deepStrictEqual(transport.typeshedActionRequests, [{ rootUri: ROOT_URI, baseRevision: "revision-1", - action: { kind: "PinCurrent" }, + action: { kind: "DownloadLatest" }, }]); - assert.deepStrictEqual(transport.applyRequests, [{ rootUri: ROOT_URI, previewId: "preview-1" }]); - assert.strictEqual(store.configurationEditor.value.phase, "ready"); - assert.strictEqual(store.configurationEditor.value.snapshot?.revision, "revision-2"); + assert.deepStrictEqual(transport.previewRequests, [], "a download never opens a preview"); + assert.deepStrictEqual(transport.applyRequests, [], "a download never applies a configuration edit"); + assert.strictEqual(store.configurationEditor.value.phase, "ready", "the editor stays interactive"); + assert.strictEqual(store.configurationEditor.value.snapshot?.revision, "revision-downloading"); + assert.strictEqual( + store.configurationEditor.value.snapshot?.typeshed.status.lifecycle.kind, + "Downloading", + "the snapshot carries the running download for the button spinner", + ); assert.strictEqual(store.configurationEditor.value.preview, undefined); } finally { controller.dispose(); @@ -584,15 +598,15 @@ suite("Configuration editor — transaction lifecycle", () => { test("a Typeshed source choice survives the apply invalidation that precedes its response", async () => { const store = createStore(); const transport = new RecordingTransport(); - const pinned = { + const custom = { ...configurationSnapshot("revision-1"), - typeshed: typeshedFixture({ source: { kind: "ExactCommit", commit: "83c2518a9e6abbda0c44592c3483de459198f887" } }), + typeshed: typeshedFixture({ source: { kind: "CustomFolder", path: "/workspace/vendor/typeshed" } }), }; - const latest = { + const pinned = { ...configurationSnapshot("revision-2"), - typeshed: typeshedFixture({ source: { kind: "Latest" } }), + typeshed: typeshedFixture(), }; - transport.snapshotResult = pinned; + transport.snapshotResult = custom; transport.previewResult = { ...configurationPreview(), typeshedChanges: [] }; let finishApply: ((snapshot: ConfigurationSnapshot) => void) | undefined; transport.applyHandler = async () => new Promise((resolve) => { finishApply = resolve; }); @@ -601,10 +615,9 @@ suite("Configuration editor — transaction lifecycle", () => { controller.open(ROOT_URI); await pollUntil(() => store.configurationEditor.value.phase === "ready"); - const chooseLatest = controller.receive({ + const choosePinned = controller.receive({ type: "preview", mutations: [ - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedCommit" } }, { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPath" } }, ], }); @@ -612,12 +625,12 @@ suite("Configuration editor — transaction lifecycle", () => { store.markConfigurationChanged({ rootUri: ROOT_URI, revision: "revision-2" }); await pollUntil(() => transport.snapshotRequests.length === 2); - finishApply?.(latest); - await chooseLatest; + finishApply?.(pinned); + await choosePinned; assert.strictEqual(store.configurationEditor.value.phase, "ready"); assert.strictEqual(store.configurationEditor.value.snapshot?.revision, "revision-2"); - assert.strictEqual(store.configurationEditor.value.snapshot?.typeshed.source.kind, "Latest"); + assert.strictEqual(store.configurationEditor.value.snapshot?.typeshed.source.kind, "ExactCommit"); } finally { controller.dispose(); } diff --git a/vscode-extension/src/test/suite/info-panel.test.ts b/vscode-extension/src/test/suite/info-panel.test.ts index 99d8b32b..44a72b4e 100644 --- a/vscode-extension/src/test/suite/info-panel.test.ts +++ b/vscode-extension/src/test/suite/info-panel.test.ts @@ -69,13 +69,11 @@ function verifyTypeshedInfoRows(): void { "file:///workspace", { lifecycle: { kind: "Ready" }, activeSource: { kind: "Bundled" }, - blockedReason: undefined, + noSourceReason: undefined, commitIdentity: "83c2518a9e6abbda0c44592c3483de459198f887", - transport: { kind: "EmbeddedZip" }, licenseStatus: { kind: "Approved" }, - provenance: { kind: "BundleVetted" }, - signedRelease: false, + licenseStatus: { kind: "Approved" }, warnings: [{ - code: "UNPINNED", message: "Pin current to make this reproducible", + code: "UNPINNED", message: "Pin a commit to make this reproducible", severity: { kind: "Advisory" }, }], }, @@ -88,21 +86,19 @@ function verifyTypeshedInfoRows(): void { assert.ok(String(source?.description).includes("83c2518a9e6abbda0c44592c3483de459198f887")); const sourceTooltip = tooltipOf(source ?? new vscode.TreeItem("missing")); assert.ok(sourceTooltip.includes("Commit: 83c2518a9e6abbda0c44592c3483de459198f887")); - assert.ok(sourceTooltip.includes("Transport: EmbeddedZip")); - assert.ok(sourceTooltip.includes("Provenance: BundleVetted")); - assert.ok(sourceTooltip.includes("Signed release: no")); + assert.ok(sourceTooltip.includes("Source: Bundled")); assert.ok(sourceTooltip.includes("License: Approved")); assert.ok(!byLabel.has("Typeshed Transport"), "trust details belong in one source tooltip"); assert.strictEqual( byLabel.get("Typeshed UNPINNED")?.description, - "Pin current to make this reproducible", + "Pin a commit to make this reproducible", ); } finally { typeshedProvider.dispose(); } } -function verifyAcquiringTypeshedSpinner(): void { +function verifyDownloadingTypeshedSpinner(): void { const store = createStore(); const writable = store.typeshedStatuses as unknown as { value: ReadonlyMap; @@ -110,10 +106,10 @@ function verifyAcquiringTypeshedSpinner(): void { writable.value = new Map([[ "file:///workspace", { - lifecycle: { kind: "Acquiring" }, blockedReason: undefined, activeSource: undefined, - commitIdentity: undefined, transport: undefined, - licenseStatus: { kind: "Acquiring" }, - provenance: { kind: "Pending" }, signedRelease: false, warnings: [], + lifecycle: { kind: "Downloading" }, noSourceReason: undefined, activeSource: undefined, + commitIdentity: undefined, + licenseStatus: { kind: "Unavailable" }, + warnings: [], }, ]]); const typeshedProvider = new InfoPanelProvider(store); @@ -137,14 +133,12 @@ function storeWithUnpinnedWarning(): ReturnType { writable.value = new Map([[ "file:///workspace", { - lifecycle: { kind: "Ready" }, activeSource: { kind: "Latest" }, - blockedReason: undefined, + lifecycle: { kind: "Ready" }, activeSource: { kind: "Bundled" }, + noSourceReason: undefined, commitIdentity: "6fb14c98ee340a07eea807a4c804e20a849eb92b", - transport: { kind: "Codeload" }, licenseStatus: { kind: "Approved" }, - provenance: { kind: "GithubTlsAttested" }, - signedRelease: false, + licenseStatus: { kind: "Approved" }, warnings: [{ - code: "UNPINNED", message: "Pin current to make this reproducible", + code: "UNPINNED", message: "Pin a commit to make this reproducible", severity: { kind: "Advisory" }, }], }, @@ -345,7 +339,7 @@ suite("Basilisk Info Panel Contents (slimmed, issue #103)", () => { test("Server Info renders the root-keyed Typeshed source and trust state", verifyTypeshedInfoRows); - test("Server Info shows an acquiring Typeshed spinner", verifyAcquiringTypeshedSpinner); + test("Server Info shows a downloading Typeshed spinner", verifyDownloadingTypeshedSpinner); test( "the UNPINNED warning row opens the configuration editor where Pin current lives", diff --git a/vscode-extension/src/test/suite/store-reactivity.test.ts b/vscode-extension/src/test/suite/store-reactivity.test.ts index 80001257..879639bb 100644 --- a/vscode-extension/src/test/suite/store-reactivity.test.ts +++ b/vscode-extension/src/test/suite/store-reactivity.test.ts @@ -184,8 +184,7 @@ suite("Typeshed status reactivity (issue #58)", () => { handles.fireNotification("basilisk/typeshedStatusChanged", { rootUri: "file:///other", status: { - lifecycle: { kind: "Ready" }, licenseStatus: { kind: "Approved" }, - provenance: { kind: "BundleVetted" }, signedRelease: false, warnings: [], + lifecycle: { kind: "Ready" }, licenseStatus: { kind: "Approved" }, warnings: [], }, }); assert.strictEqual(store.typeshedStatuses.value.has("file:///other"), true); @@ -193,8 +192,7 @@ suite("Typeshed status reactivity (issue #58)", () => { handles.fireNotification("basilisk/typeshedStatusChanged", { rootUri: "file:///workspace", status: { - lifecycle: { kind: "Ready" }, licenseStatus: { kind: "Approved" }, - provenance: { kind: "BundleVetted" }, signedRelease: false, warnings: [], + lifecycle: { kind: "Ready" }, licenseStatus: { kind: "Approved" }, warnings: [], }, }); assert.strictEqual( @@ -210,9 +208,8 @@ suite("Typeshed status reactivity (issue #58)", () => { const initial = { rootUri: "file:///workspace", status: { - lifecycle: { kind: "Blocked" }, blockedReason: "exact unavailable", - licenseStatus: { kind: "Changed" }, provenance: { kind: "Pending" }, - signedRelease: false, warnings: [], + lifecycle: { kind: "NoSource" }, noSourceReason: "exact unavailable", + licenseStatus: { kind: "Changed" }, warnings: [], }, }; const { store, handles } = storeWithFakeClient([initial]); @@ -225,7 +222,7 @@ suite("Typeshed status reactivity (issue #58)", () => { rootUri: "file:///workspace", status: { lifecycle: { kind: "invented" } }, }); assert.strictEqual( - store.typeshedStatuses.value.get("file:///workspace")?.blockedReason, + store.typeshedStatuses.value.get("file:///workspace")?.noSourceReason, "exact unavailable", ); }); diff --git a/vscode-extension/src/test/suite/typeshed-fixture.ts b/vscode-extension/src/test/suite/typeshed-fixture.ts index 3fa596a6..be62a356 100644 --- a/vscode-extension/src/test/suite/typeshed-fixture.ts +++ b/vscode-extension/src/test/suite/typeshed-fixture.ts @@ -3,73 +3,55 @@ import type { TypeshedConfigurationState, - TypeshedDownloadPolicy, TypeshedSource, TypeshedStatusState, } from "../../configuration-editor-model"; export const ACTIVE_COMMIT = "83c2518a9e6abbda0c44592c3483de459198f887"; export const OTHER_COMMIT = "1f2e3d4c5b6a798877665544332211000ffeeddc"; +/** What resolving python/typeshed@main yields for a Download latest run. */ +export const LATEST_COMMIT = "aaaabbbbccccddddeeeeffff0000111122223333"; export interface TypeshedFixtureOptions { readonly source?: TypeshedSource; - readonly downloads?: TypeshedDownloadPolicy | undefined; - readonly pinnableCommit?: string | undefined; + readonly storeFolder?: string | undefined; readonly licenseAvailable?: boolean; - readonly acquiring?: boolean; + readonly downloading?: boolean; + readonly noSourceReason?: string; readonly warnings?: TypeshedStatusState["warnings"]; } -export const DEFAULT_DOWNLOADS: TypeshedDownloadPolicy = { - reuseDownloads: true, - verifyContent: true, - archiveUrl: undefined, - cacheFolder: undefined, -}; - -function defaultDownloads( - source: TypeshedSource, - options: TypeshedFixtureOptions, -): TypeshedDownloadPolicy | undefined { - if ("downloads" in options) { return options.downloads; } - return source.kind === "CustomFolder" ? undefined : DEFAULT_DOWNLOADS; -} - -function defaultPin( - source: TypeshedSource, - acquiring: boolean, - options: TypeshedFixtureOptions, -): string | undefined { - if ("pinnableCommit" in options) { return options.pinnableCommit; } - return !acquiring && source.kind === "Latest" ? ACTIVE_COMMIT : undefined; +function fixtureLifecycle(options: TypeshedFixtureOptions): TypeshedStatusState["lifecycle"] { + if (options.downloading === true) { return { kind: "Downloading" }; } + return options.noSourceReason === undefined ? { kind: "Ready" } : { kind: "NoSource" }; } -function fixtureStatus(acquiring: boolean, warnings: TypeshedStatusState["warnings"]): TypeshedStatusState { +function fixtureStatus(options: TypeshedFixtureOptions): TypeshedStatusState { + const lifecycle = fixtureLifecycle(options); + const ready = lifecycle.kind === "Ready"; return { - lifecycle: { kind: acquiring ? "Acquiring" : "Ready" }, - blockedReason: undefined, - activeSource: acquiring ? undefined : { kind: "Bundled" }, - commitIdentity: acquiring ? undefined : ACTIVE_COMMIT, - transport: acquiring ? undefined : { kind: "EmbeddedZip" }, - licenseStatus: { kind: acquiring ? "Acquiring" : "Approved" }, - provenance: { kind: acquiring ? "Pending" : "BundleVetted" }, - signedRelease: false, - warnings, + lifecycle, + noSourceReason: lifecycle.kind === "NoSource" ? options.noSourceReason : undefined, + activeSource: ready ? { kind: "Bundled" } : undefined, + commitIdentity: ready ? ACTIVE_COMMIT : undefined, + licenseStatus: { kind: ready ? "Approved" : "Unavailable" }, + warnings: options.warnings ?? [], }; } /** - * The server's projection for a settled root. Callers pass only what their - * scenario changes; every other field stays the realistic default. + * The server's projection for one root. Callers pass only what their scenario + * changes; every other field stays the realistic default: the pinned-commit + * source (the only default source — there is no "Latest") with no store + * folder configured. */ export function typeshedFixture(options: TypeshedFixtureOptions = {}): TypeshedConfigurationState { - const acquiring = options.acquiring === true; - const source = options.source ?? { kind: "Latest" }; + const source = options.source ?? { kind: "ExactCommit", commit: ACTIVE_COMMIT }; return { source, - downloads: defaultDownloads(source, options), - pinnableCommit: defaultPin(source, acquiring, options), - licenseAvailable: options.licenseAvailable ?? !acquiring, - status: fixtureStatus(acquiring, options.warnings ?? []), + // A custom folder downloads nothing, so it has no store folder at all. + storeFolder: source.kind === "CustomFolder" ? undefined : options.storeFolder, + licenseAvailable: options.licenseAvailable ?? source.kind !== "CustomFolder", + status: fixtureStatus(options), }; } diff --git a/vscode-extension/src/test/suite/webview-dom-harness.ts b/vscode-extension/src/test/suite/webview-dom-harness.ts index 4f56a9fb..29cbf128 100644 --- a/vscode-extension/src/test/suite/webview-dom-harness.ts +++ b/vscode-extension/src/test/suite/webview-dom-harness.ts @@ -15,7 +15,7 @@ import type { EditorMutation, TypeshedConfigurationState, } from "../../configuration-editor-model"; -import { ACTIVE_COMMIT, typeshedFixture } from "./typeshed-fixture"; +import { ACTIVE_COMMIT, LATEST_COMMIT, typeshedFixture } from "./typeshed-fixture"; export const RESULT_TIMEOUT_MS = 30_000; const PEP_RULE_COUNT = 40; @@ -43,28 +43,26 @@ export interface ScenarioOutcome { export interface HostConfig { commit?: string; path?: string; - archiveUrl?: string; - cacheFolder?: string; - reuseDownloads?: boolean; - verifyContent?: boolean; + storeFolder?: string; } -function typeshedFor(config: HostConfig, acquiring: boolean): TypeshedConfigurationState { +/** The lifecycle facts the fake server holds beside the configuration. */ +interface HostLifecycle { + readonly downloading: boolean; + readonly noSourceReason: string | undefined; +} + +// With neither key configured the bundled commit is serving: the source is +// still ExactCommit — there is no "Latest" source at all ([LSPCFGED-TYPESHED]). +function typeshedFor(config: HostConfig, lifecycle: HostLifecycle): TypeshedConfigurationState { const source = config.path !== undefined ? ({ kind: "CustomFolder", path: config.path } as const) - : config.commit !== undefined - ? ({ kind: "ExactCommit", commit: config.commit } as const) - : ({ kind: "Latest" } as const); + : ({ kind: "ExactCommit", commit: config.commit ?? ACTIVE_COMMIT } as const); return typeshedFixture({ source, - acquiring, - downloads: source.kind === "CustomFolder" ? undefined : { - reuseDownloads: config.reuseDownloads ?? true, - verifyContent: config.verifyContent ?? true, - archiveUrl: config.archiveUrl, - cacheFolder: config.cacheFolder, - }, - pinnableCommit: !acquiring && source.kind === "Latest" ? ACTIVE_COMMIT : undefined, + storeFolder: config.storeFolder, + downloading: lifecycle.downloading, + noSourceReason: lifecycle.downloading ? undefined : lifecycle.noSourceReason, }); } @@ -142,7 +140,9 @@ function isTypeshedMutation(mutation: EditorMutation): boolean { export class ScenarioHost { public readonly intents: Record[] = []; private readonly config: HostConfig; - private acquiring: boolean; + private downloading: boolean; + private noSourceReason: string | undefined; + private pendingDownload: "DownloadLatest" | "DownloadPinned" | undefined; private revision = 0; private pending: EditorMutation[] = []; private ruleEntries = new Map(); @@ -152,26 +152,34 @@ export class ScenarioHost { constructor(options: { config?: HostConfig; - acquiring?: boolean; + downloading?: boolean; + noSourceReason?: string; folders?: (string | undefined)[]; focusRule?: string | null; } = {}) { this.config = { ...options.config }; - this.acquiring = options.acquiring === true; + this.downloading = options.downloading === true; + this.noSourceReason = options.noSourceReason; this.folders = [...(options.folders ?? [])]; this.focusRule = options.focusRule ?? null; } - /** Settle an in-flight acquisition, as the server's status notification does. */ + /** Complete an in-flight download, as the server's status notification does. */ public settle(): Record { - this.acquiring = false; + if (this.pendingDownload === "DownloadLatest") { + this.config.commit = LATEST_COMMIT; + this.config.path = undefined; + } + this.pendingDownload = undefined; + this.downloading = false; + this.noSourceReason = undefined; return this.readyState(); } public snapshot(): ConfigurationSnapshot { this.revision += 1; const snapshot = fixtureSnapshot( - typeshedFor(this.config, this.acquiring), + typeshedFor(this.config, { downloading: this.downloading, noSourceReason: this.noSourceReason }), `fnv1a64:${this.revision}`, ); return { @@ -251,28 +259,25 @@ export class ScenarioHost { /** The writer's closed allowlist, in the same shape the TOML holds. */ private write(mutation: EditorMutation): void { if (mutation.kind !== "SetTypeshedSetting" && mutation.kind !== "RemoveTypeshedSetting") { return; } - const value = mutation.kind === "SetTypeshedSetting" ? mutation.value : undefined; - const text = value?.kind === "Text" ? value.value : undefined; - const flag = value?.kind === "Boolean" ? value.value : undefined; + const text = mutation.kind === "SetTypeshedSetting" ? mutation.value : undefined; const fields: Record = { TypeshedCommit: "commit", TypeshedPath: "path", - TypeshedUrl: "archiveUrl", - TypeshedCachePath: "cacheFolder", - TypeshedCache: "reuseDownloads", - TypeshedVerify: "verifyContent", + TypeshedStorePath: "storeFolder", }; const field = fields[mutation.key.kind]; if (field === undefined) { return; } - Object.assign(this.config, { [field]: text ?? flag }); + Object.assign(this.config, { [field]: text }); } + // A download is not a configuration edit: the action returns the refreshed + // snapshot at once (lifecycle Downloading) and completion arrives later as + // a status notification — settle() ([LSPCFGED-TYPESHED-DOWNLOAD]). private typeshedAction(action: string): Record | undefined { - if (action !== "PinCurrent") { return undefined; } - // The server pins the ACTIVE commit and clears any custom folder. - this.config.commit = ACTIVE_COMMIT; - this.config.path = undefined; - return this.readyState("Applied"); + if (action !== "DownloadLatest" && action !== "DownloadPinned") { return undefined; } + this.pendingDownload = action; + this.downloading = true; + return this.readyState("Downloading the standard library…"); } private pickFolder(key: string): Record { @@ -284,7 +289,7 @@ export class ScenarioHost { this.config.path = folder; this.config.commit = undefined; } else { - this.config.cacheFolder = folder; + this.config.storeFolder = folder; } return this.readyState("Applied"); } @@ -415,10 +420,9 @@ export const DRIVER_PRELUDE = String.raw` const commit = el('[data-typeshed-commit]'); const commitError = document.getElementById('typeshed-commit-error'); const path = el('[data-typeshed-path="TypeshedPath"]'); - const reuse = el('[data-typeshed-boolean="TypeshedCache"]'); - const verify = el('[data-typeshed-boolean="TypeshedVerify"]'); - const url = el('[data-typeshed-text="TypeshedUrl"]'); - const cacheFolder = el('[data-typeshed-path="TypeshedCachePath"]'); + const storeFolder = el('[data-typeshed-path="TypeshedStorePath"]'); + const pickFolder = el('[data-pick-typeshed-folder="TypeshedPath"]'); + const noSource = el('.typeshed-no-source'); const status = {}; const rows = all('#typeshed-status dt'); rows.forEach((dt, index) => { status[text(dt)] = text(all('#typeshed-status dd')[index]); }); @@ -436,22 +440,28 @@ export const DRIVER_PRELUDE = String.raw` commitError: commitError && !commitError.hidden ? text(commitError) : null, pathPresent: path !== null, pathValue: path ? path.value : null, - reusePresent: reuse !== null, - reuseChecked: reuse ? reuse.checked : null, - reuseDisabled: reuse ? reuse.disabled : null, - verifyPresent: verify !== null, - verifyChecked: verify ? verify.checked : null, + pickFolderDisabled: pickFolder ? pickFolder.disabled : null, + storePickerDisabled: el('[data-pick-typeshed-folder="TypeshedStorePath"]') + ? el('[data-pick-typeshed-folder="TypeshedStorePath"]').disabled : null, + textControls: all('[data-typeshed-text]').length, advancedPresent: el('.typeshed-advanced') !== null, advancedOpen: el('.typeshed-advanced') ? el('.typeshed-advanced').open : null, - urlValue: url ? url.value : null, - cacheFolderValue: cacheFolder ? cacheFolder.value : null, + storeFolderValue: storeFolder ? storeFolder.value : null, + booleanControls: all('[data-typeshed-boolean]').length, actions: all('[data-typeshed-action]').map((button) => ({ action: button.dataset.typeshedAction, label: text(button), disabled: button.disabled, + busy: button.classList.contains('busy'), })), status, warnings: all('.typeshed-warning').map(text), + noSourcePresent: noSource !== null, + noSourceText: text(noSource), + // The deleted lock screen must stay deleted: no overlay node, no inert + // shell, ever ([LSPCFGED-TYPESHED-DOWNLOAD]). + overlayPresent: document.getElementById('state-overlay') !== null, + shellInert: document.getElementById('shell').inert === true, dialogOpen: document.getElementById('preview-dialog').open, dialogChanges: text(document.getElementById('preview-changes')), }; diff --git a/vscode-extension/test-fixtures/workspace/.vscode/settings.json b/vscode-extension/test-fixtures/workspace/.vscode/settings.json index fdc16455..d64392ce 100644 --- a/vscode-extension/test-fixtures/workspace/.vscode/settings.json +++ b/vscode-extension/test-fixtures/workspace/.vscode/settings.json @@ -1,4 +1,3 @@ { - "basilisk.analysisMode": "wholeModule", - "basilisk.enabled": true + "basilisk.analysisMode": "wholeModule" } \ No newline at end of file diff --git a/vscode-license-manifest.json b/vscode-license-manifest.json index a9f21b4b..4a103da3 100644 --- a/vscode-license-manifest.json +++ b/vscode-license-manifest.json @@ -1,5 +1,5 @@ { - "carrier_sha256": "1d9f60c90143c251d0b48c53a9da4858f36c9d4d092d7fe6914b96d1dc213d12", + "carrier_sha256": "c4dca1716c57de4fc4fb03abb305ad354667cb0f19990ebaa0beb57841b7145f", "dependencies": [ { "name": "@nimblesite/shipwright-core", @@ -63,11 +63,11 @@ }, { "name": "brace-expansion", - "version": "5.0.6", + "version": "5.0.7", "license": "MIT", - "repository": "git+ssh://git@github.com/juliangruber/brace-expansion.git", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "repository": "git+https://github.com/juliangruber/brace-expansion.git", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "legal_files": [ { "label": "LICENSE", @@ -202,5 +202,5 @@ ] } ], - "production_graph_sha256": "0cde329d95c6111d14152a9e04f4bd890377383c0fa5994f216c19b3834a0767" + "production_graph_sha256": "43f5f5fd0daae31342024112becb94923452b92c929a2820b1eb50eca7f9f186" } diff --git a/website/package-lock.json b/website/package-lock.json index 54ebe752..f2fb7749 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -813,9 +813,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/website/src/_data/navigation.json b/website/src/_data/navigation.json index e304528f..17e53b8e 100644 --- a/website/src/_data/navigation.json +++ b/website/src/_data/navigation.json @@ -23,6 +23,7 @@ { "title": "Overview", "titleZh": "概览", "url": "/docs/installation/" }, { "title": "VS Code & Cursor", "titleZh": "VS Code 与 Cursor", "url": "/docs/install-vscode/" }, { "title": "Zed", "titleZh": "Zed", "url": "/docs/install-zed/" }, + { "title": "Neovim", "titleZh": "Neovim", "url": "/docs/install-neovim/" }, { "title": "CLI & package managers", "titleZh": "CLI 与包管理器", "url": "/docs/install-cli/" } ] }, diff --git a/website/src/_data/rules.json b/website/src/_data/rules.json index 8fc479d9..ae4420fc 100644 --- a/website/src/_data/rules.json +++ b/website/src/_data/rules.json @@ -25,7 +25,25 @@ } ], "group": "Missing Annotations", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0001" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0001", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "BSK-0002", @@ -49,7 +67,25 @@ } ], "group": "Missing Annotations", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0002" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0002", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "BSK-0003", @@ -68,7 +104,25 @@ } ], "group": "Missing Annotations", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0003" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0003", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "BSK-0004", @@ -87,7 +141,25 @@ } ], "group": "Missing Annotations", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0004" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0004", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "BSK-0005", @@ -110,7 +182,25 @@ } ], "group": "Missing Annotations", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0005" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0005", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "BSK-0011", @@ -139,7 +229,13 @@ } ], "group": "Dependencies", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0011" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0011", + "references": [ + { + "label": "PEP 621", + "url": "https://peps.python.org/pep-0621/" + } + ] }, { "code": "BSK-0012", @@ -162,7 +258,13 @@ } ], "group": "Dependencies", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0012" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0012", + "references": [ + { + "label": "PEP 621", + "url": "https://peps.python.org/pep-0621/" + } + ] }, { "code": "BSK-0013", @@ -185,7 +287,13 @@ } ], "group": "Dependencies", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0013" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0013", + "references": [ + { + "label": "uv: Locking and syncing", + "url": "https://docs.astral.sh/uv/concepts/projects/sync/" + } + ] }, { "code": "BSK-0014", @@ -214,7 +322,17 @@ } ], "group": "Style", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0014" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0014", + "references": [ + { + "label": "Typing spec: Special types in annotations", + "url": "https://typing.python.org/en/latest/spec/special-types.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "BSK-0025", @@ -229,7 +347,7 @@ "body": [ { "type": "text", - "html": "When a class overrides a method that is also defined in one of its base classes (both defined within the same module), the overriding method must carry the @override decorator (PEP 698 / typing.override)." + "html": "When a class overrides a method that is also defined in one of its base classes (both defined within the same module), the overriding method must carry the @override decorator (PEP 698 / typing.override)." }, { "type": "text", @@ -241,11 +359,17 @@ }, { "type": "text", - "html": "Version gate (issue #171): @override (PEP 698 / typing.override) was introduced in Python 3.12, so suggesting it on an older configured target is a false positive \u2014 the decorator cannot be imported there. BSK-0025 is silent when the configured python_version is below 3.12." + "html": "Version gate (issue #171): @override (PEP 698 / typing.override) was introduced in Python 3.12, so suggesting it on an older configured target is a false positive \u2014 the decorator cannot be imported there. BSK-0025 is silent when the configured python_version is below 3.12." } ], "group": "Missing Annotations", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0025" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0025", + "references": [ + { + "label": "PEP 698", + "url": "https://peps.python.org/pep-0698/" + } + ] }, { "code": "BSK-0040", @@ -269,7 +393,25 @@ } ], "group": "Missing Annotations", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0040" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0040", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "BSK-0050", @@ -294,7 +436,25 @@ } ], "group": "Redundancy", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0050" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0050", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "BSK-0060", @@ -313,7 +473,8 @@ } ], "group": "Suppressions", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0060" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0060", + "references": [] }, { "code": "BSK-0061", @@ -332,7 +493,8 @@ } ], "group": "Suppressions", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0061" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0061", + "references": [] }, { "code": "BSK-0062", @@ -351,7 +513,8 @@ } ], "group": "Suppressions", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0062" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0062", + "references": [] }, { "code": "BSK-0063", @@ -370,7 +533,8 @@ } ], "group": "Suppressions", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0063" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0063", + "references": [] }, { "code": "BSK-0152", @@ -394,7 +558,17 @@ } ], "group": "Stubs", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0152" + "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0152", + "references": [ + { + "label": "Typing spec: Distributing type information", + "url": "https://typing.python.org/en/latest/spec/distributing.html" + }, + { + "label": "PEP 561", + "url": "https://peps.python.org/pep-0561/" + } + ] }, { "code": "aliases_implicit", @@ -409,7 +583,7 @@ "body": [ { "type": "text", - "html": "PEP 613 requires that the RHS of an explicit TypeAlias annotation must be a valid type expression. The following are errors:" + "html": "PEP 613 requires that the RHS of an explicit TypeAlias annotation must be a valid type expression. The following are errors:" }, { "type": "text", @@ -422,7 +596,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/aliases_implicit" + "docsUrl": "https://www.basilisk-python.dev/errors/aliases_implicit", + "references": [ + { + "label": "Typing spec: Type aliases", + "url": "https://typing.python.org/en/latest/spec/aliases.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 613", + "url": "https://peps.python.org/pep-0613/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + } + ] }, { "code": "aliases_newtype", @@ -437,7 +629,7 @@ "body": [ { "type": "text", - "html": "PEP 484 places restrictions on NewType:" + "html": "PEP 484 places restrictions on NewType:" }, { "type": "text", @@ -450,7 +642,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/aliases_newtype" + "docsUrl": "https://www.basilisk-python.dev/errors/aliases_newtype", + "references": [ + { + "label": "Typing spec: Type aliases", + "url": "https://typing.python.org/en/latest/spec/aliases.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 613", + "url": "https://peps.python.org/pep-0613/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + } + ] }, { "code": "aliases_recursive", @@ -474,7 +684,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/aliases_recursive" + "docsUrl": "https://www.basilisk-python.dev/errors/aliases_recursive", + "references": [ + { + "label": "Typing spec: Type aliases", + "url": "https://typing.python.org/en/latest/spec/aliases.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 613", + "url": "https://peps.python.org/pep-0613/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + } + ] }, { "code": "aliases_type_statement", @@ -485,11 +713,11 @@ "aliases" ], "summary": "Invalid RHS in a PEP 695 `type X = rhs` statement", - "summaryHtml": "Invalid RHS in a PEP 695 type X = rhs statement", + "summaryHtml": "Invalid RHS in a PEP 695 type X = rhs statement", "body": [ { "type": "text", - "html": "PEP 695 requires the RHS of a type statement to be a valid type expression. The same restrictions as TypeAlias (aliases_implicit) apply." + "html": "PEP 695 requires the RHS of a type statement to be a valid type expression. The same restrictions as TypeAlias (aliases_implicit) apply." }, { "type": "code", @@ -498,7 +726,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/aliases_type_statement" + "docsUrl": "https://www.basilisk-python.dev/errors/aliases_type_statement", + "references": [ + { + "label": "Typing spec: Type aliases", + "url": "https://typing.python.org/en/latest/spec/aliases.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 613", + "url": "https://peps.python.org/pep-0613/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + } + ] }, { "code": "aliases_typealiastype", @@ -538,7 +784,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/aliases_typealiastype" + "docsUrl": "https://www.basilisk-python.dev/errors/aliases_typealiastype", + "references": [ + { + "label": "Typing spec: Type aliases", + "url": "https://typing.python.org/en/latest/spec/aliases.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 613", + "url": "https://peps.python.org/pep-0613/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + } + ] }, { "code": "annotations_forward_refs", @@ -553,7 +817,7 @@ "body": [ { "type": "text", - "html": "PEP 484 requires that annotations contain valid type expressions. Only certain expression forms are valid as types:" + "html": "PEP 484 requires that annotations contain valid type expressions. Only certain expression forms are valid as types:" }, { "type": "text", @@ -574,7 +838,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/annotations_forward_refs" + "docsUrl": "https://www.basilisk-python.dev/errors/annotations_forward_refs", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "annotations_generators", @@ -606,7 +888,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/annotations_generators" + "docsUrl": "https://www.basilisk-python.dev/errors/annotations_generators", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "annotations_generators_2", @@ -630,7 +930,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/annotations_generators_2" + "docsUrl": "https://www.basilisk-python.dev/errors/annotations_generators_2", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "annotations_typeexpr", @@ -654,7 +972,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/annotations_typeexpr" + "docsUrl": "https://www.basilisk-python.dev/errors/annotations_typeexpr", + "references": [ + { + "label": "Typing spec: Type annotations", + "url": "https://typing.python.org/en/latest/spec/annotations.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 3107", + "url": "https://peps.python.org/pep-3107/" + } + ] }, { "code": "assignment_compatibility", @@ -685,7 +1021,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/assignment_compatibility" + "docsUrl": "https://www.basilisk-python.dev/errors/assignment_compatibility", + "references": [ + { + "label": "Typing spec: Type system concepts", + "url": "https://typing.python.org/en/latest/spec/concepts.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "callables_annotation", @@ -712,7 +1058,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/callables_annotation" + "docsUrl": "https://www.basilisk-python.dev/errors/callables_annotation", + "references": [ + { + "label": "Typing spec: Callables", + "url": "https://typing.python.org/en/latest/spec/callables.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 692", + "url": "https://peps.python.org/pep-0692/" + } + ] }, { "code": "callables_kwargs", @@ -731,7 +1095,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/callables_kwargs" + "docsUrl": "https://www.basilisk-python.dev/errors/callables_kwargs", + "references": [ + { + "label": "Typing spec: Callables", + "url": "https://typing.python.org/en/latest/spec/callables.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 692", + "url": "https://peps.python.org/pep-0692/" + } + ] }, { "code": "callables_protocol", @@ -750,7 +1132,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/callables_protocol" + "docsUrl": "https://www.basilisk-python.dev/errors/callables_protocol", + "references": [ + { + "label": "Typing spec: Callables", + "url": "https://typing.python.org/en/latest/spec/callables.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 692", + "url": "https://peps.python.org/pep-0692/" + } + ] }, { "code": "callables_protocol_2", @@ -769,7 +1169,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/callables_protocol_2" + "docsUrl": "https://www.basilisk-python.dev/errors/callables_protocol_2", + "references": [ + { + "label": "Typing spec: Callables", + "url": "https://typing.python.org/en/latest/spec/callables.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 692", + "url": "https://peps.python.org/pep-0692/" + } + ] }, { "code": "callables_subtyping", @@ -801,7 +1219,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/callables_subtyping" + "docsUrl": "https://www.basilisk-python.dev/errors/callables_subtyping", + "references": [ + { + "label": "Typing spec: Callables", + "url": "https://typing.python.org/en/latest/spec/callables.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 692", + "url": "https://peps.python.org/pep-0692/" + } + ] }, { "code": "calls_argument_count", @@ -828,7 +1264,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/calls_argument_count" + "docsUrl": "https://www.basilisk-python.dev/errors/calls_argument_count", + "references": [ + { + "label": "Typing spec: Callables", + "url": "https://typing.python.org/en/latest/spec/callables.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "calls_argument_type", @@ -851,7 +1297,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/calls_argument_type" + "docsUrl": "https://www.basilisk-python.dev/errors/calls_argument_type", + "references": [ + { + "label": "Typing spec: Callables", + "url": "https://typing.python.org/en/latest/spec/callables.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "classes_classvar", @@ -866,7 +1322,7 @@ "body": [ { "type": "text", - "html": "PEP 526 and the typing spec restrict ClassVarT to:" + "html": "PEP 526 and the typing spec restrict ClassVarT to:" }, { "type": "text", @@ -895,7 +1351,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/classes_classvar" + "docsUrl": "https://www.basilisk-python.dev/errors/classes_classvar", + "references": [ + { + "label": "Typing spec: Class type assignability", + "url": "https://typing.python.org/en/latest/spec/class-compat.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 698", + "url": "https://peps.python.org/pep-0698/" + } + ] }, { "code": "classes_override", @@ -923,7 +1397,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/classes_override" + "docsUrl": "https://www.basilisk-python.dev/errors/classes_override", + "references": [ + { + "label": "Typing spec: Class type assignability", + "url": "https://typing.python.org/en/latest/spec/class-compat.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 698", + "url": "https://peps.python.org/pep-0698/" + } + ] }, { "code": "classes_override_2", @@ -947,7 +1439,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/classes_override_2" + "docsUrl": "https://www.basilisk-python.dev/errors/classes_override_2", + "references": [ + { + "label": "Typing spec: Class type assignability", + "url": "https://typing.python.org/en/latest/spec/class-compat.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 698", + "url": "https://peps.python.org/pep-0698/" + } + ] }, { "code": "classes_override_3", @@ -962,7 +1472,7 @@ "body": [ { "type": "text", - "html": "PEP 698 \u2014 a method decorated @override (or typing.override) must actually override a method declared in a base class. When no ancestor declares a method of that name, the decorator is a lie and the type checker should report it." + "html": "PEP 698 \u2014 a method decorated @override (or typing.override) must actually override a method declared in a base class. When no ancestor declares a method of that name, the decorator is a lie and the type checker should report it." }, { "type": "text", @@ -975,7 +1485,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/classes_override_3" + "docsUrl": "https://www.basilisk-python.dev/errors/classes_override_3", + "references": [ + { + "label": "Typing spec: Class type assignability", + "url": "https://typing.python.org/en/latest/spec/class-compat.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 698", + "url": "https://peps.python.org/pep-0698/" + } + ] }, { "code": "constructors_call_init", @@ -1014,7 +1542,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/constructors_call_init" + "docsUrl": "https://www.basilisk-python.dev/errors/constructors_call_init", + "references": [ + { + "label": "Typing spec: Constructors", + "url": "https://typing.python.org/en/latest/spec/constructors.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "constructors_call_new", @@ -1050,7 +1588,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/constructors_call_new" + "docsUrl": "https://www.basilisk-python.dev/errors/constructors_call_new", + "references": [ + { + "label": "Typing spec: Constructors", + "url": "https://typing.python.org/en/latest/spec/constructors.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "constructors_call_type", @@ -1081,7 +1629,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/constructors_call_type" + "docsUrl": "https://www.basilisk-python.dev/errors/constructors_call_type", + "references": [ + { + "label": "Typing spec: Constructors", + "url": "https://typing.python.org/en/latest/spec/constructors.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "constructors_callable", @@ -1122,7 +1680,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/constructors_callable" + "docsUrl": "https://www.basilisk-python.dev/errors/constructors_callable", + "references": [ + { + "label": "Typing spec: Constructors", + "url": "https://typing.python.org/en/latest/spec/constructors.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "dataclasses_frozen", @@ -1146,7 +1714,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_frozen" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_frozen", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dataclasses_hash", @@ -1170,11 +1752,25 @@ }, { "type": "text", - "html": "PEP 557 specifies the __hash__ synthesis rules: - If eq is true and frozen is false, __hash__ is set to None. - If eq is true and frozen is true, Python synthesises a __hash__. - If unsafe_hash is true, Python synthesises a __hash__ regardless. - If eq is false, __hash__ is left untouched (inherited from parent). - If the class defines __hash__ explicitly, that definition is used." + "html": "PEP 557 specifies the __hash__ synthesis rules: - If eq is true and frozen is false, __hash__ is set to None. - If eq is true and frozen is true, Python synthesises a __hash__. - If unsafe_hash is true, Python synthesises a __hash__ regardless. - If eq is false, __hash__ is left untouched (inherited from parent). - If the class defines __hash__ explicitly, that definition is used." } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_hash" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_hash", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dataclasses_inheritance", @@ -1198,7 +1794,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_inheritance" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_inheritance", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dataclasses_kwonly", @@ -1222,7 +1832,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_kwonly" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_kwonly", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dataclasses_match_args", @@ -1246,7 +1870,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_match_args" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_match_args", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dataclasses_order", @@ -1274,7 +1912,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_order" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_order", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dataclasses_postinit", @@ -1306,7 +1958,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_postinit" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_postinit", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dataclasses_slots", @@ -1330,7 +1996,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_slots" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_slots", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dataclasses_transform_class", @@ -1358,7 +2038,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_transform_class" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_transform_class", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dataclasses_transform_meta", @@ -1386,7 +2080,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_transform_meta" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_transform_meta", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dataclasses_usage", @@ -1410,7 +2118,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_usage" + "docsUrl": "https://www.basilisk-python.dev/errors/dataclasses_usage", + "references": [ + { + "label": "Typing spec: Dataclasses", + "url": "https://typing.python.org/en/latest/spec/dataclasses.html" + }, + { + "label": "PEP 557", + "url": "https://peps.python.org/pep-0557/" + }, + { + "label": "PEP 681", + "url": "https://peps.python.org/pep-0681/" + } + ] }, { "code": "dict_key_hashable", @@ -1433,7 +2155,13 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/dict_key_hashable" + "docsUrl": "https://www.basilisk-python.dev/errors/dict_key_hashable", + "references": [ + { + "label": "Typing spec: Type system concepts", + "url": "https://typing.python.org/en/latest/spec/concepts.html" + } + ] }, { "code": "directives_assert_type", @@ -1456,7 +2184,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/directives_assert_type" + "docsUrl": "https://www.basilisk-python.dev/errors/directives_assert_type", + "references": [ + { + "label": "Typing spec: Type checker directives", + "url": "https://typing.python.org/en/latest/spec/directives.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 702", + "url": "https://peps.python.org/pep-0702/" + } + ] }, { "code": "directives_assert_type_2", @@ -1480,7 +2222,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/directives_assert_type_2" + "docsUrl": "https://www.basilisk-python.dev/errors/directives_assert_type_2", + "references": [ + { + "label": "Typing spec: Type checker directives", + "url": "https://typing.python.org/en/latest/spec/directives.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 702", + "url": "https://peps.python.org/pep-0702/" + } + ] }, { "code": "directives_cast", @@ -1503,7 +2259,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/directives_cast" + "docsUrl": "https://www.basilisk-python.dev/errors/directives_cast", + "references": [ + { + "label": "Typing spec: Type checker directives", + "url": "https://typing.python.org/en/latest/spec/directives.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 702", + "url": "https://peps.python.org/pep-0702/" + } + ] }, { "code": "directives_deprecated", @@ -1518,7 +2288,7 @@ "body": [ { "type": "text", - "html": "PEP 702 introduces @deprecated from typing / typing_extensions. Using a deprecated entity (calling, importing, accessing) should produce a diagnostic so that developers migrate away from the deprecated API." + "html": "PEP 702 introduces @deprecated from typing / typing_extensions. Using a deprecated entity (calling, importing, accessing) should produce a diagnostic so that developers migrate away from the deprecated API." }, { "type": "code", @@ -1527,7 +2297,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/directives_deprecated" + "docsUrl": "https://www.basilisk-python.dev/errors/directives_deprecated", + "references": [ + { + "label": "Typing spec: Type checker directives", + "url": "https://typing.python.org/en/latest/spec/directives.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 702", + "url": "https://peps.python.org/pep-0702/" + } + ] }, { "code": "directives_disjoint_base", @@ -1538,11 +2322,11 @@ "directives" ], "summary": "PEP 800 disjoint bases", - "summaryHtml": "PEP 800 disjoint bases", + "summaryHtml": "PEP 800 disjoint bases", "body": [ { "type": "text", - "html": "PEP 800 introduces typing.disjoint_base. A class is a disjoint base when it is decorated @disjoint_base or defines a non-empty __slots__. A class definition must have a single dominating disjoint base among its bases:" + "html": "PEP 800 introduces typing.disjoint_base. A class is a disjoint base when it is decorated @disjoint_base or defines a non-empty __slots__. A class definition must have a single dominating disjoint base among its bases:" }, { "type": "code", @@ -1555,7 +2339,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/directives_disjoint_base" + "docsUrl": "https://www.basilisk-python.dev/errors/directives_disjoint_base", + "references": [ + { + "label": "Typing spec: Type checker directives", + "url": "https://typing.python.org/en/latest/spec/directives.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 702", + "url": "https://peps.python.org/pep-0702/" + }, + { + "label": "PEP 800", + "url": "https://peps.python.org/pep-0800/" + } + ] }, { "code": "directives_reveal_type", @@ -1578,7 +2380,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/directives_reveal_type" + "docsUrl": "https://www.basilisk-python.dev/errors/directives_reveal_type", + "references": [ + { + "label": "Typing spec: Type checker directives", + "url": "https://typing.python.org/en/latest/spec/directives.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 702", + "url": "https://peps.python.org/pep-0702/" + } + ] }, { "code": "directives_version_platform", @@ -1602,7 +2418,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/directives_version_platform" + "docsUrl": "https://www.basilisk-python.dev/errors/directives_version_platform", + "references": [ + { + "label": "Typing spec: Type checker directives", + "url": "https://typing.python.org/en/latest/spec/directives.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 702", + "url": "https://peps.python.org/pep-0702/" + } + ] }, { "code": "enums_behaviors", @@ -1626,7 +2456,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/enums_behaviors" + "docsUrl": "https://www.basilisk-python.dev/errors/enums_behaviors", + "references": [ + { + "label": "Typing spec: Enumerations", + "url": "https://typing.python.org/en/latest/spec/enums.html" + }, + { + "label": "PEP 435", + "url": "https://peps.python.org/pep-0435/" + } + ] }, { "code": "enums_definition", @@ -1654,7 +2494,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/enums_definition" + "docsUrl": "https://www.basilisk-python.dev/errors/enums_definition", + "references": [ + { + "label": "Typing spec: Enumerations", + "url": "https://typing.python.org/en/latest/spec/enums.html" + }, + { + "label": "PEP 435", + "url": "https://peps.python.org/pep-0435/" + } + ] }, { "code": "enums_expansion", @@ -1678,7 +2528,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/enums_expansion" + "docsUrl": "https://www.basilisk-python.dev/errors/enums_expansion", + "references": [ + { + "label": "Typing spec: Enumerations", + "url": "https://typing.python.org/en/latest/spec/enums.html" + }, + { + "label": "PEP 435", + "url": "https://peps.python.org/pep-0435/" + } + ] }, { "code": "enums_member_values", @@ -1702,7 +2562,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/enums_member_values" + "docsUrl": "https://www.basilisk-python.dev/errors/enums_member_values", + "references": [ + { + "label": "Typing spec: Enumerations", + "url": "https://typing.python.org/en/latest/spec/enums.html" + }, + { + "label": "PEP 435", + "url": "https://peps.python.org/pep-0435/" + } + ] }, { "code": "enums_members", @@ -1730,7 +2600,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/enums_members" + "docsUrl": "https://www.basilisk-python.dev/errors/enums_members", + "references": [ + { + "label": "Typing spec: Enumerations", + "url": "https://typing.python.org/en/latest/spec/enums.html" + }, + { + "label": "PEP 435", + "url": "https://peps.python.org/pep-0435/" + } + ] }, { "code": "enums_members_2", @@ -1754,7 +2634,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/enums_members_2" + "docsUrl": "https://www.basilisk-python.dev/errors/enums_members_2", + "references": [ + { + "label": "Typing spec: Enumerations", + "url": "https://typing.python.org/en/latest/spec/enums.html" + }, + { + "label": "PEP 435", + "url": "https://peps.python.org/pep-0435/" + } + ] }, { "code": "generics_base_class", @@ -1769,11 +2659,41 @@ "body": [ { "type": "text", - "html": "Each type parameter in GenericT1, T2, ... must be unique. GenericT, T is an error per PEP 484." + "html": "Each type parameter in GenericT1, T2, ... must be unique. GenericT, T is an error per PEP 484." } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_base_class" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_base_class", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_base_class_2", @@ -1801,7 +2721,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_base_class_2" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_base_class_2", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_base_class_3", @@ -1825,7 +2775,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_base_class_3" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_base_class_3", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_basic", @@ -1840,11 +2820,41 @@ "body": [ { "type": "text", - "html": "PEP 484 requires a TypeVar to have either zero constraints (unconstrained) or two or more constraints. A single constraint makes no sense because it would be equivalent to using the type directly." + "html": "PEP 484 requires a TypeVar to have either zero constraints (unconstrained) or two or more constraints. A single constraint makes no sense because it would be equivalent to using the type directly." } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_basic" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_basic", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_basic_2", @@ -1859,7 +2869,7 @@ "body": [ { "type": "text", - "html": "PEP 484 requires that all arguments to Generic... and Protocol... be type variable names (TypeVar, TypeVarTuple, or ParamSpec). Passing a concrete type (e.g. Genericint) is a type error." + "html": "PEP 484 requires that all arguments to Generic... and Protocol... be type variable names (TypeVar, TypeVarTuple, or ParamSpec). Passing a concrete type (e.g. Genericint) is a type error." }, { "type": "code", @@ -1868,7 +2878,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_basic_2" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_basic_2", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_basic_3", @@ -1903,7 +2943,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_basic_3" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_basic_3", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_defaults", @@ -1918,7 +2988,7 @@ "body": [ { "type": "text", - "html": "PEP 696 \u00a7Ordering defines two ordering rules for type parameters in Generic...:" + "html": "PEP 696 \u00a7Ordering defines two ordering rules for type parameters in Generic...:" }, { "type": "text", @@ -1930,7 +3000,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_defaults" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_defaults", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_defaults_2", @@ -1945,7 +3045,7 @@ "body": [ { "type": "text", - "html": "PEP 696 specifies two constraints on TypeVar defaults:" + "html": "PEP 696 specifies two constraints on TypeVar defaults:" }, { "type": "text", @@ -1962,7 +3062,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_defaults_2" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_defaults_2", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_defaults_referential", @@ -1977,7 +3107,7 @@ "body": [ { "type": "text", - "html": "PEP 696 specifies constraints on TypeVar defaults that reference other TypeVars:" + "html": "PEP 696 specifies constraints on TypeVar defaults that reference other TypeVars:" }, { "type": "text", @@ -1990,7 +3120,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_defaults_referential" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_defaults_referential", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_defaults_referential_2", @@ -2005,7 +3165,7 @@ "body": [ { "type": "text", - "html": "PEP 696 defines rules for when a TypeVar default references another TypeVar:" + "html": "PEP 696 defines rules for when a TypeVar default references another TypeVar:" }, { "type": "text", @@ -2018,7 +3178,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_defaults_referential_2" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_defaults_referential_2", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_defaults_specialization", @@ -2050,7 +3240,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_defaults_specialization" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_defaults_specialization", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_scoping", @@ -2065,7 +3285,7 @@ "body": [ { "type": "text", - "html": "A type variable used in a type annotation must be "in scope" \u2014 i.e. it must be bound by a surrounding generic class (GenericT), PEP 695 type parameter, or function signature parameter." + "html": "A type variable used in a type annotation must be "in scope" \u2014 i.e. it must be bound by a surrounding generic class (GenericT), PEP 695 type parameter, or function signature parameter." }, { "type": "text", @@ -2078,7 +3298,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_scoping" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_scoping", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_self_attributes", @@ -2106,7 +3356,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_self_attributes" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_self_attributes", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_self_basic", @@ -2138,7 +3418,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_self_basic" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_self_basic", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_self_protocols", @@ -2162,7 +3472,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_self_protocols" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_self_protocols", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_self_usage", @@ -2177,7 +3517,7 @@ "body": [ { "type": "text", - "html": "PEP 673 defines Self as a special type that refers to the current class. It is only valid in specific locations:" + "html": "PEP 673 defines Self as a special type that refers to the current class. It is only valid in specific locations:" }, { "type": "text", @@ -2198,7 +3538,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_self_usage" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_self_usage", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_syntax_compatibility", @@ -2209,11 +3579,11 @@ "generics" ], "summary": "PEP 695 type parameter syntax mixed with traditional `TypeVars`", - "summaryHtml": "PEP 695 type parameter syntax mixed with traditional TypeVars", + "summaryHtml": "PEP 695 type parameter syntax mixed with traditional TypeVars", "body": [ { "type": "text", - "html": "PEP 695 introduced a new syntax for declaring type parameters (class FooT and def fooT()). When a class or function uses this new syntax, it must not reference traditional TypeVar instances from an outer scope in its base classes or parameter annotations." + "html": "PEP 695 introduced a new syntax for declaring type parameters (class FooT and def fooT()). When a class or function uses this new syntax, it must not reference traditional TypeVar instances from an outer scope in its base classes or parameter annotations." }, { "type": "code", @@ -2222,7 +3592,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_syntax_compatibility" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_syntax_compatibility", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_syntax_declarations", @@ -2233,11 +3633,11 @@ "generics" ], "summary": "Invalid PEP 695 type parameter bound or constraint", - "summaryHtml": "Invalid PEP 695 type parameter bound or constraint", + "summaryHtml": "Invalid PEP 695 type parameter bound or constraint", "body": [ { "type": "text", - "html": "PEP 695 introduced a new syntax for declaring type parameters in class and function definitions. The bound/constraint expression after : is restricted to specific forms; invalid forms are caught by this rule." + "html": "PEP 695 introduced a new syntax for declaring type parameters in class and function definitions. The bound/constraint expression after : is restricted to specific forms; invalid forms are caught by this rule." }, { "type": "code", @@ -2246,7 +3646,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_syntax_declarations" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_syntax_declarations", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_syntax_declarations_2", @@ -2261,7 +3691,7 @@ "body": [ { "type": "text", - "html": "When a PEP 695 type parameter has a bound (e.g., T: str), attribute accesses on parameters typed as T must be valid for the bound type." + "html": "When a PEP 695 type parameter has a bound (e.g., T: str), attribute accesses on parameters typed as T must be valid for the bound type." }, { "type": "code", @@ -2270,7 +3700,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_syntax_declarations_2" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_syntax_declarations_2", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_syntax_scoping", @@ -2281,11 +3741,11 @@ "generics" ], "summary": "PEP 695 generic type parameter scoping violations", - "summaryHtml": "PEP 695 generic type parameter scoping violations", + "summaryHtml": "PEP 695 generic type parameter scoping violations", "body": [ { "type": "text", - "html": "Detects violations of PEP 695 type-parameter scoping rules, driven entirely by ruff_python_ast nodes (via basilisk_resolver::Pep695Scoping) \u2014 never by raw source.lines() scanning, so docstring/comment/string content is never mistaken for real class / def / type declarations." + "html": "Detects violations of PEP 695 type-parameter scoping rules, driven entirely by ruff_python_ast nodes (via basilisk_resolver::Pep695Scoping) \u2014 never by raw source.lines() scanning, so docstring/comment/string content is never mistaken for real class / def / type declarations." }, { "type": "text", @@ -2302,7 +3762,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_syntax_scoping" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_syntax_scoping", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_type_erasure", @@ -2326,7 +3816,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_type_erasure" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_type_erasure", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_typevartuple_args", @@ -2350,7 +3870,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_args" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_args", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_typevartuple_basic", @@ -2365,7 +3915,7 @@ "body": [ { "type": "text", - "html": "PEP 484 / PEP 695 forbid certain combinations of keyword arguments in TypeVar(...) calls, and PEP 646 / PEP 612 restrict what kwargs TypeVarTuple and ParamSpec accept:" + "html": "PEP 484 / PEP 695 forbid certain combinations of keyword arguments in TypeVar(...) calls, and PEP 646 / PEP 612 restrict what kwargs TypeVarTuple and ParamSpec accept:" }, { "type": "text", @@ -2378,7 +3928,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_basic" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_basic", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_typevartuple_basic_2", @@ -2393,7 +3973,7 @@ "body": [ { "type": "text", - "html": "When a TypeVarTuple is used in a generic class base list or as a direct type annotation, it must be unpacked using the * operator. Using a TypeVarTuple without unpacking is invalid per PEP 646." + "html": "When a TypeVarTuple is used in a generic class base list or as a direct type annotation, it must be unpacked using the * operator. Using a TypeVarTuple without unpacking is invalid per PEP 646." }, { "type": "code", @@ -2402,7 +3982,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_basic_2" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_basic_2", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_typevartuple_basic_3", @@ -2426,7 +4036,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_basic_3" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_basic_3", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_typevartuple_callable", @@ -2450,7 +4090,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_callable" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_callable", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_typevartuple_specialization", @@ -2474,7 +4144,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_specialization" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_specialization", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_typevartuple_specialization_2", @@ -2511,7 +4211,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_specialization_2" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_specialization_2", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_typevartuple_unpack", @@ -2535,7 +4265,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_unpack" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_typevartuple_unpack", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_upper_bound", @@ -2559,7 +4319,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_upper_bound" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_upper_bound", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_upper_bound_2", @@ -2583,7 +4373,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_upper_bound_2" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_upper_bound_2", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_variance", @@ -2607,7 +4427,37 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_variance" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_variance", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "generics_variance_inference", @@ -2630,11 +4480,41 @@ }, { "type": "text", - "html": "Per PEP 484: "A generic class nested in another generic class cannot use the same type variables."" + "html": "Per PEP 484: "A generic class nested in another generic class cannot use the same type variables."" } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/generics_variance_inference" + "docsUrl": "https://www.basilisk-python.dev/errors/generics_variance_inference", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 612", + "url": "https://peps.python.org/pep-0612/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + }, + { + "label": "PEP 673", + "url": "https://peps.python.org/pep-0673/" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + }, + { + "label": "PEP 696", + "url": "https://peps.python.org/pep-0696/" + } + ] }, { "code": "historical_positional", @@ -2649,7 +4529,7 @@ "body": [ { "type": "text", - "html": "Before PEP 570 (Python 3.8), the convention for marking parameters as positional-only was to prefix their names with __ (double underscore) without a trailing __. Type checkers must support this historical mechanism." + "html": "Before PEP 570 (Python 3.8), the convention for marking parameters as positional-only was to prefix their names with __ (double underscore) without a trailing __. Type checkers must support this historical mechanism." }, { "type": "text", @@ -2657,7 +4537,7 @@ }, { "type": "text", - "html": "1. **PositionalOnlyAfterKeyword**: A __-prefixed positional-only parameter appears after a regular positional-or-keyword parameter in a function that does not use PEP 570 / syntax." + "html": "1. **PositionalOnlyAfterKeyword**: A __-prefixed positional-only parameter appears after a regular positional-or-keyword parameter in a function that does not use PEP 570 / syntax." }, { "type": "text", @@ -2670,7 +4550,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/historical_positional" + "docsUrl": "https://www.basilisk-python.dev/errors/historical_positional", + "references": [ + { + "label": "Typing spec: Historical and deprecated features", + "url": "https://typing.python.org/en/latest/spec/historical.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 570", + "url": "https://peps.python.org/pep-0570/" + } + ] }, { "code": "imports_module_attribute", @@ -2701,7 +4595,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/imports_module_attribute" + "docsUrl": "https://www.basilisk-python.dev/errors/imports_module_attribute", + "references": [ + { + "label": "Typing spec: Distributing type information", + "url": "https://typing.python.org/en/latest/spec/distributing.html" + }, + { + "label": "PEP 561", + "url": "https://peps.python.org/pep-0561/" + } + ] }, { "code": "imports_unresolved", @@ -2723,7 +4627,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/imports_unresolved" + "docsUrl": "https://www.basilisk-python.dev/errors/imports_unresolved", + "references": [ + { + "label": "Typing spec: Distributing type information", + "url": "https://typing.python.org/en/latest/spec/distributing.html" + }, + { + "label": "PEP 561", + "url": "https://peps.python.org/pep-0561/" + } + ] }, { "code": "literals_literalstring", @@ -2755,7 +4669,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/literals_literalstring" + "docsUrl": "https://www.basilisk-python.dev/errors/literals_literalstring", + "references": [ + { + "label": "Typing spec: Literals", + "url": "https://typing.python.org/en/latest/spec/literal.html" + }, + { + "label": "PEP 586", + "url": "https://peps.python.org/pep-0586/" + }, + { + "label": "PEP 675", + "url": "https://peps.python.org/pep-0675/" + } + ] }, { "code": "literals_parameterizations", @@ -2770,7 +4698,7 @@ "body": [ { "type": "text", - "html": "PEP 586 restricts what values may appear inside Literal.... Only these are legal: - Integer literals (decimal, hex, binary, octal; optionally signed) - String literals (str and bytes) - Boolean literals (True, False) - None - Enum member access (Color.RED) - Nested Literal..." + "html": "PEP 586 restricts what values may appear inside Literal.... Only these are legal: - Integer literals (decimal, hex, binary, octal; optionally signed) - String literals (str and bytes) - Boolean literals (True, False) - None - Enum member access (Color.RED) - Nested Literal..." }, { "type": "text", @@ -2778,7 +4706,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/literals_parameterizations" + "docsUrl": "https://www.basilisk-python.dev/errors/literals_parameterizations", + "references": [ + { + "label": "Typing spec: Literals", + "url": "https://typing.python.org/en/latest/spec/literal.html" + }, + { + "label": "PEP 586", + "url": "https://peps.python.org/pep-0586/" + }, + { + "label": "PEP 675", + "url": "https://peps.python.org/pep-0675/" + } + ] }, { "code": "literals_parameterizations_2", @@ -2802,7 +4744,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/literals_parameterizations_2" + "docsUrl": "https://www.basilisk-python.dev/errors/literals_parameterizations_2", + "references": [ + { + "label": "Typing spec: Literals", + "url": "https://typing.python.org/en/latest/spec/literal.html" + }, + { + "label": "PEP 586", + "url": "https://peps.python.org/pep-0586/" + }, + { + "label": "PEP 675", + "url": "https://peps.python.org/pep-0675/" + } + ] }, { "code": "literals_semantics", @@ -2830,7 +4786,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/literals_semantics" + "docsUrl": "https://www.basilisk-python.dev/errors/literals_semantics", + "references": [ + { + "label": "Typing spec: Literals", + "url": "https://typing.python.org/en/latest/spec/literal.html" + }, + { + "label": "PEP 586", + "url": "https://peps.python.org/pep-0586/" + }, + { + "label": "PEP 675", + "url": "https://peps.python.org/pep-0675/" + } + ] }, { "code": "literals_semantics_2", @@ -2849,7 +4819,7 @@ }, { "type": "text", - "html": "1. **Literal0 vs LiteralFalse non-equivalence (PEP 586)**: Literal0 and LiteralFalse are distinct types despite 0 == False in Python. Assigning a Literal0-typed parameter to a LiteralFalse local (or vice versa) is a type error." + "html": "1. **Literal0 vs LiteralFalse non-equivalence (PEP 586)**: Literal0 and LiteralFalse are distinct types despite 0 == False in Python. Assigning a Literal0-typed parameter to a LiteralFalse local (or vice versa) is a type error." }, { "type": "text", @@ -2862,7 +4832,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/literals_semantics_2" + "docsUrl": "https://www.basilisk-python.dev/errors/literals_semantics_2", + "references": [ + { + "label": "Typing spec: Literals", + "url": "https://typing.python.org/en/latest/spec/literal.html" + }, + { + "label": "PEP 586", + "url": "https://peps.python.org/pep-0586/" + }, + { + "label": "PEP 675", + "url": "https://peps.python.org/pep-0675/" + } + ] }, { "code": "match_exhaustiveness", @@ -2884,7 +4868,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/match_exhaustiveness" + "docsUrl": "https://www.basilisk-python.dev/errors/match_exhaustiveness", + "references": [ + { + "label": "Typing spec: Type narrowing", + "url": "https://typing.python.org/en/latest/spec/narrowing.html" + }, + { + "label": "PEP 634", + "url": "https://peps.python.org/pep-0634/" + } + ] }, { "code": "namedtuples_define_class", @@ -2919,7 +4913,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/namedtuples_define_class" + "docsUrl": "https://www.basilisk-python.dev/errors/namedtuples_define_class", + "references": [ + { + "label": "Typing spec: Named Tuples", + "url": "https://typing.python.org/en/latest/spec/namedtuples.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "namedtuples_define_functional", @@ -2951,7 +4955,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/namedtuples_define_functional" + "docsUrl": "https://www.basilisk-python.dev/errors/namedtuples_define_functional", + "references": [ + { + "label": "Typing spec: Named Tuples", + "url": "https://typing.python.org/en/latest/spec/namedtuples.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "namedtuples_type_compat", @@ -2979,7 +4993,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/namedtuples_type_compat" + "docsUrl": "https://www.basilisk-python.dev/errors/namedtuples_type_compat", + "references": [ + { + "label": "Typing spec: Named Tuples", + "url": "https://typing.python.org/en/latest/spec/namedtuples.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "namedtuples_usage", @@ -3007,7 +5031,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/namedtuples_usage" + "docsUrl": "https://www.basilisk-python.dev/errors/namedtuples_usage", + "references": [ + { + "label": "Typing spec: Named Tuples", + "url": "https://typing.python.org/en/latest/spec/namedtuples.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "names_unbound", @@ -3030,7 +5064,13 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/names_unbound" + "docsUrl": "https://www.basilisk-python.dev/errors/names_unbound", + "references": [ + { + "label": "Python language reference: Naming and binding", + "url": "https://docs.python.org/3/reference/executionmodel.html#naming-and-binding" + } + ] }, { "code": "names_undefined", @@ -3053,7 +5093,13 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/names_undefined" + "docsUrl": "https://www.basilisk-python.dev/errors/names_undefined", + "references": [ + { + "label": "Python language reference: Naming and binding", + "url": "https://docs.python.org/3/reference/executionmodel.html#naming-and-binding" + } + ] }, { "code": "narrowing_typeguard", @@ -3072,7 +5118,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/narrowing_typeguard" + "docsUrl": "https://www.basilisk-python.dev/errors/narrowing_typeguard", + "references": [ + { + "label": "Typing spec: Type narrowing", + "url": "https://typing.python.org/en/latest/spec/narrowing.html" + }, + { + "label": "PEP 647", + "url": "https://peps.python.org/pep-0647/" + }, + { + "label": "PEP 742", + "url": "https://peps.python.org/pep-0742/" + } + ] }, { "code": "narrowing_typeis", @@ -3096,7 +5156,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/narrowing_typeis" + "docsUrl": "https://www.basilisk-python.dev/errors/narrowing_typeis", + "references": [ + { + "label": "Typing spec: Type narrowing", + "url": "https://typing.python.org/en/latest/spec/narrowing.html" + }, + { + "label": "PEP 647", + "url": "https://peps.python.org/pep-0647/" + }, + { + "label": "PEP 742", + "url": "https://peps.python.org/pep-0742/" + } + ] }, { "code": "narrowing_typeis_2", @@ -3115,7 +5189,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/narrowing_typeis_2" + "docsUrl": "https://www.basilisk-python.dev/errors/narrowing_typeis_2", + "references": [ + { + "label": "Typing spec: Type narrowing", + "url": "https://typing.python.org/en/latest/spec/narrowing.html" + }, + { + "label": "PEP 647", + "url": "https://peps.python.org/pep-0647/" + }, + { + "label": "PEP 742", + "url": "https://peps.python.org/pep-0742/" + } + ] }, { "code": "overloads_basic", @@ -3139,7 +5227,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/overloads_basic" + "docsUrl": "https://www.basilisk-python.dev/errors/overloads_basic", + "references": [ + { + "label": "Typing spec: Overloads", + "url": "https://typing.python.org/en/latest/spec/overload.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "overloads_consistency", @@ -3162,7 +5260,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/overloads_consistency" + "docsUrl": "https://www.basilisk-python.dev/errors/overloads_consistency", + "references": [ + { + "label": "Typing spec: Overloads", + "url": "https://typing.python.org/en/latest/spec/overload.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "overloads_consistency_2", @@ -3185,7 +5293,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/overloads_consistency_2" + "docsUrl": "https://www.basilisk-python.dev/errors/overloads_consistency_2", + "references": [ + { + "label": "Typing spec: Overloads", + "url": "https://typing.python.org/en/latest/spec/overload.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "overloads_consistency_3", @@ -3208,7 +5326,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/overloads_consistency_3" + "docsUrl": "https://www.basilisk-python.dev/errors/overloads_consistency_3", + "references": [ + { + "label": "Typing spec: Overloads", + "url": "https://typing.python.org/en/latest/spec/overload.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "overloads_definitions", @@ -3231,7 +5359,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/overloads_definitions" + "docsUrl": "https://www.basilisk-python.dev/errors/overloads_definitions", + "references": [ + { + "label": "Typing spec: Overloads", + "url": "https://typing.python.org/en/latest/spec/overload.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "overloads_evaluation", @@ -3255,7 +5393,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/overloads_evaluation" + "docsUrl": "https://www.basilisk-python.dev/errors/overloads_evaluation", + "references": [ + { + "label": "Typing spec: Overloads", + "url": "https://typing.python.org/en/latest/spec/overload.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "protocols_class_objects", @@ -3283,7 +5431,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_class_objects" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_class_objects", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_class_objects_2", @@ -3315,7 +5473,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_class_objects_2" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_class_objects_2", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_definition", @@ -3343,7 +5511,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_definition" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_definition", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_definition_2", @@ -3379,7 +5557,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_definition_2" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_definition_2", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_explicit", @@ -3403,7 +5591,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_explicit" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_explicit", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_explicit_2", @@ -3427,7 +5625,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_explicit_2" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_explicit_2", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_explicit_3", @@ -3451,7 +5659,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_explicit_3" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_explicit_3", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_generic", @@ -3487,11 +5705,21 @@ }, { "type": "text", - "html": "PEP 544: <https://typing.readthedocs.io/en/latest/spec/protocol.html#generic-protocols>" + "html": "PEP 544: <https://typing.readthedocs.io/en/latest/spec/protocol.html#generic-protocols>" } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_generic" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_generic", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_merging", @@ -3506,7 +5734,7 @@ "body": [ { "type": "text", - "html": "Per PEP 544, a Protocol class may only inherit from other Protocol classes (with the exception of object). Inheriting from a non-Protocol concrete class is a violation." + "html": "Per PEP 544, a Protocol class may only inherit from other Protocol classes (with the exception of object). Inheriting from a non-Protocol concrete class is a violation." }, { "type": "code", @@ -3515,7 +5743,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_merging" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_merging", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_modules", @@ -3547,7 +5785,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_modules" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_modules", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_runtime_checkable", @@ -3562,7 +5810,7 @@ "body": [ { "type": "text", - "html": "Per PEP 544: - A protocol can be used as the second argument to isinstance() or issubclass() **only** if it is decorated with @runtime_checkable. - issubclass() can only be used with **non-data** protocols (protocols that define only methods, not data attributes)." + "html": "Per PEP 544: - A protocol can be used as the second argument to isinstance() or issubclass() **only** if it is decorated with @runtime_checkable. - issubclass() can only be used with **non-data** protocols (protocols that define only methods, not data attributes)." }, { "type": "code", @@ -3571,7 +5819,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_runtime_checkable" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_runtime_checkable", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_runtime_checkable_2", @@ -3586,7 +5844,7 @@ "body": [ { "type": "text", - "html": "Per PEP 544: - A protocol can be used as the second argument to isinstance() or issubclass() **only** if it is decorated with @runtime_checkable. - issubclass() can only be used with **non-data** protocols (protocols that define only methods, not data attributes). - Type checkers should reject an isinstance() or issubclass() call if there is an unsafe overlap between the type of the first argument and the protocol." + "html": "Per PEP 544: - A protocol can be used as the second argument to isinstance() or issubclass() **only** if it is decorated with @runtime_checkable. - issubclass() can only be used with **non-data** protocols (protocols that define only methods, not data attributes). - Type checkers should reject an isinstance() or issubclass() call if there is an unsafe overlap between the type of the first argument and the protocol." }, { "type": "code", @@ -3595,7 +5853,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_runtime_checkable_2" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_runtime_checkable_2", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_subtyping", @@ -3619,7 +5887,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_subtyping" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_subtyping", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_variance", @@ -3646,7 +5924,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_variance" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_variance", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "protocols_variance_2", @@ -3665,7 +5953,7 @@ }, { "type": "text", - "html": "PEP 544 specifies that type checkers should warn when the inferred variance of a type variable used in a protocol differs from its declared variance." + "html": "PEP 544 specifies that type checkers should warn when the inferred variance of a type variable used in a protocol differs from its declared variance." }, { "type": "code", @@ -3674,7 +5962,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/protocols_variance_2" + "docsUrl": "https://www.basilisk-python.dev/errors/protocols_variance_2", + "references": [ + { + "label": "Typing spec: Protocols", + "url": "https://typing.python.org/en/latest/spec/protocol.html" + }, + { + "label": "PEP 544", + "url": "https://peps.python.org/pep-0544/" + } + ] }, { "code": "qualifiers_annotated", @@ -3689,7 +5987,7 @@ "body": [ { "type": "text", - "html": "PEP 593 requires that the first argument to Annotated... be a valid type expression. The following are errors:" + "html": "PEP 593 requires that the first argument to Annotated... be a valid type expression. The following are errors:" }, { "type": "text", @@ -3706,7 +6004,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/qualifiers_annotated" + "docsUrl": "https://www.basilisk-python.dev/errors/qualifiers_annotated", + "references": [ + { + "label": "Typing spec: Type qualifiers", + "url": "https://typing.python.org/en/latest/spec/qualifiers.html" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 591", + "url": "https://peps.python.org/pep-0591/" + }, + { + "label": "PEP 593", + "url": "https://peps.python.org/pep-0593/" + } + ] }, { "code": "qualifiers_annotated_2", @@ -3721,7 +6037,7 @@ "body": [ { "type": "text", - "html": "PEP 593 requires Annotated to be subscripted with at least two arguments: a type and one or more metadata values. Annotatedint with only a single argument is a type error." + "html": "PEP 593 requires Annotated to be subscripted with at least two arguments: a type and one or more metadata values. Annotatedint with only a single argument is a type error." }, { "type": "code", @@ -3730,7 +6046,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/qualifiers_annotated_2" + "docsUrl": "https://www.basilisk-python.dev/errors/qualifiers_annotated_2", + "references": [ + { + "label": "Typing spec: Type qualifiers", + "url": "https://typing.python.org/en/latest/spec/qualifiers.html" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 591", + "url": "https://peps.python.org/pep-0591/" + }, + { + "label": "PEP 593", + "url": "https://peps.python.org/pep-0593/" + } + ] }, { "code": "qualifiers_final_annotation", @@ -3745,7 +6079,7 @@ "body": [ { "type": "text", - "html": "PEP 591 restricts FinalT to:" + "html": "PEP 591 restricts FinalT to:" }, { "type": "text", @@ -3766,7 +6100,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/qualifiers_final_annotation" + "docsUrl": "https://www.basilisk-python.dev/errors/qualifiers_final_annotation", + "references": [ + { + "label": "Typing spec: Type qualifiers", + "url": "https://typing.python.org/en/latest/spec/qualifiers.html" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 591", + "url": "https://peps.python.org/pep-0591/" + }, + { + "label": "PEP 593", + "url": "https://peps.python.org/pep-0593/" + } + ] }, { "code": "qualifiers_final_annotation_2", @@ -3781,7 +6133,7 @@ "body": [ { "type": "text", - "html": "Detects violations of PEP 591's rules for the Final qualifier, beyond the positional errors handled by E0044. Specifically:" + "html": "Detects violations of PEP 591's rules for the Final qualifier, beyond the positional errors handled by E0044. Specifically:" }, { "type": "text", @@ -3821,7 +6173,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/qualifiers_final_annotation_2" + "docsUrl": "https://www.basilisk-python.dev/errors/qualifiers_final_annotation_2", + "references": [ + { + "label": "Typing spec: Type qualifiers", + "url": "https://typing.python.org/en/latest/spec/qualifiers.html" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 591", + "url": "https://peps.python.org/pep-0591/" + }, + { + "label": "PEP 593", + "url": "https://peps.python.org/pep-0593/" + } + ] }, { "code": "qualifiers_final_decorator", @@ -3852,7 +6222,25 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/qualifiers_final_decorator" + "docsUrl": "https://www.basilisk-python.dev/errors/qualifiers_final_decorator", + "references": [ + { + "label": "Typing spec: Type qualifiers", + "url": "https://typing.python.org/en/latest/spec/qualifiers.html" + }, + { + "label": "PEP 526", + "url": "https://peps.python.org/pep-0526/" + }, + { + "label": "PEP 591", + "url": "https://peps.python.org/pep-0591/" + }, + { + "label": "PEP 593", + "url": "https://peps.python.org/pep-0593/" + } + ] }, { "code": "returns_compatibility", @@ -3875,7 +6263,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/returns_compatibility" + "docsUrl": "https://www.basilisk-python.dev/errors/returns_compatibility", + "references": [ + { + "label": "Typing spec: Type system concepts", + "url": "https://typing.python.org/en/latest/spec/concepts.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "returns_compatibility_2", @@ -3893,7 +6291,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/returns_compatibility_2" + "docsUrl": "https://www.basilisk-python.dev/errors/returns_compatibility_2", + "references": [ + { + "label": "Typing spec: Type system concepts", + "url": "https://typing.python.org/en/latest/spec/concepts.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "specialtypes_never", @@ -3933,7 +6341,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/specialtypes_never" + "docsUrl": "https://www.basilisk-python.dev/errors/specialtypes_never", + "references": [ + { + "label": "Typing spec: Special types in annotations", + "url": "https://typing.python.org/en/latest/spec/special-types.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "specialtypes_never_2", @@ -3961,7 +6379,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/specialtypes_never_2" + "docsUrl": "https://www.basilisk-python.dev/errors/specialtypes_never_2", + "references": [ + { + "label": "Typing spec: Special types in annotations", + "url": "https://typing.python.org/en/latest/spec/special-types.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "specialtypes_promotions", @@ -3976,7 +6404,7 @@ "body": [ { "type": "text", - "html": "The Python typing spec (PEP 484 / typing spec \u00a7Special cases for float and complex) states that int is not a subtype of float for static type-checking purposes. Attributes such as numerator and denominator are defined on int but NOT on float. Accessing them on a parameter declared as float is therefore a static type error." + "html": "The Python typing spec (PEP 484 / typing spec \u00a7Special cases for float and complex) states that int is not a subtype of float for static type-checking purposes. Attributes such as numerator and denominator are defined on int but NOT on float. Accessing them on a parameter declared as float is therefore a static type error." }, { "type": "text", @@ -3989,7 +6417,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/specialtypes_promotions" + "docsUrl": "https://www.basilisk-python.dev/errors/specialtypes_promotions", + "references": [ + { + "label": "Typing spec: Special types in annotations", + "url": "https://typing.python.org/en/latest/spec/special-types.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "specialtypes_type", @@ -4024,7 +6462,17 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/specialtypes_type" + "docsUrl": "https://www.basilisk-python.dev/errors/specialtypes_type", + "references": [ + { + "label": "Typing spec: Special types in annotations", + "url": "https://typing.python.org/en/latest/spec/special-types.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + } + ] }, { "code": "tuples_index", @@ -4057,7 +6505,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/tuples_index" + "docsUrl": "https://www.basilisk-python.dev/errors/tuples_index", + "references": [ + { + "label": "Typing spec: Tuples", + "url": "https://typing.python.org/en/latest/spec/tuples.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + } + ] }, { "code": "tuples_index_2", @@ -4081,7 +6543,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/tuples_index_2" + "docsUrl": "https://www.basilisk-python.dev/errors/tuples_index_2", + "references": [ + { + "label": "Typing spec: Tuples", + "url": "https://typing.python.org/en/latest/spec/tuples.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + } + ] }, { "code": "tuples_type_compat", @@ -4121,7 +6597,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/tuples_type_compat" + "docsUrl": "https://www.basilisk-python.dev/errors/tuples_type_compat", + "references": [ + { + "label": "Typing spec: Tuples", + "url": "https://typing.python.org/en/latest/spec/tuples.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + } + ] }, { "code": "tuples_type_form", @@ -4149,7 +6639,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/tuples_type_form" + "docsUrl": "https://www.basilisk-python.dev/errors/tuples_type_form", + "references": [ + { + "label": "Typing spec: Tuples", + "url": "https://typing.python.org/en/latest/spec/tuples.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + } + ] }, { "code": "tuples_type_form_2", @@ -4164,7 +6668,7 @@ "body": [ { "type": "text", - "html": "Validates tuple type annotations according to PEP 646 rules:" + "html": "Validates tuple type annotations according to PEP 646 rules:" }, { "type": "text", @@ -4177,7 +6681,21 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/tuples_type_form_2" + "docsUrl": "https://www.basilisk-python.dev/errors/tuples_type_form_2", + "references": [ + { + "label": "Typing spec: Tuples", + "url": "https://typing.python.org/en/latest/spec/tuples.html" + }, + { + "label": "PEP 484", + "url": "https://peps.python.org/pep-0484/" + }, + { + "label": "PEP 646", + "url": "https://peps.python.org/pep-0646/" + } + ] }, { "code": "typeddicts_alt_syntax", @@ -4200,7 +6718,29 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_alt_syntax" + "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_alt_syntax", + "references": [ + { + "label": "Typing spec: Typed dictionaries", + "url": "https://typing.python.org/en/latest/spec/typeddict.html" + }, + { + "label": "PEP 589", + "url": "https://peps.python.org/pep-0589/" + }, + { + "label": "PEP 655", + "url": "https://peps.python.org/pep-0655/" + }, + { + "label": "PEP 705", + "url": "https://peps.python.org/pep-0705/" + }, + { + "label": "PEP 728", + "url": "https://peps.python.org/pep-0728/" + } + ] }, { "code": "typeddicts_class_syntax", @@ -4215,11 +6755,33 @@ "body": [ { "type": "text", - "html": "TypedDict classes (PEP 589) are restricted to key declarations only. Defining methods (other than __init__ which is synthesised) is an error." + "html": "TypedDict classes (PEP 589) are restricted to key declarations only. Defining methods (other than __init__ which is synthesised) is an error." } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_class_syntax" + "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_class_syntax", + "references": [ + { + "label": "Typing spec: Typed dictionaries", + "url": "https://typing.python.org/en/latest/spec/typeddict.html" + }, + { + "label": "PEP 589", + "url": "https://peps.python.org/pep-0589/" + }, + { + "label": "PEP 655", + "url": "https://peps.python.org/pep-0655/" + }, + { + "label": "PEP 705", + "url": "https://peps.python.org/pep-0705/" + }, + { + "label": "PEP 728", + "url": "https://peps.python.org/pep-0728/" + } + ] }, { "code": "typeddicts_class_syntax_2", @@ -4234,7 +6796,7 @@ "body": [ { "type": "text", - "html": "TypedDict class syntax only accepts total=True/False as a keyword argument. Using metaclass= or any unrecognised keyword is an error per PEP 589." + "html": "TypedDict class syntax only accepts total=True/False as a keyword argument. Using metaclass= or any unrecognised keyword is an error per PEP 589." }, { "type": "text", @@ -4242,7 +6804,29 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_class_syntax_2" + "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_class_syntax_2", + "references": [ + { + "label": "Typing spec: Typed dictionaries", + "url": "https://typing.python.org/en/latest/spec/typeddict.html" + }, + { + "label": "PEP 589", + "url": "https://peps.python.org/pep-0589/" + }, + { + "label": "PEP 655", + "url": "https://peps.python.org/pep-0655/" + }, + { + "label": "PEP 705", + "url": "https://peps.python.org/pep-0705/" + }, + { + "label": "PEP 728", + "url": "https://peps.python.org/pep-0728/" + } + ] }, { "code": "typeddicts_extra_items", @@ -4253,15 +6837,37 @@ "typeddicts" ], "summary": "`TypedDict` `extra_items` / `closed` (PEP 728) violations", - "summaryHtml": "TypedDict extra_items / closed (PEP 728) violations", + "summaryHtml": "TypedDict extra_items / closed (PEP 728) violations", "body": [ { "type": "text", - "html": "Validates class-definition legality, dict-literal construction, assignability between TypedDicts, and constructor calls against the PEP 728 rules. Operates on the module AST and is independent of resolver state." + "html": "Validates class-definition legality, dict-literal construction, assignability between TypedDicts, and constructor calls against the PEP 728 rules. Operates on the module AST and is independent of resolver state." } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_extra_items" + "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_extra_items", + "references": [ + { + "label": "Typing spec: Typed dictionaries", + "url": "https://typing.python.org/en/latest/spec/typeddict.html" + }, + { + "label": "PEP 589", + "url": "https://peps.python.org/pep-0589/" + }, + { + "label": "PEP 655", + "url": "https://peps.python.org/pep-0655/" + }, + { + "label": "PEP 705", + "url": "https://peps.python.org/pep-0705/" + }, + { + "label": "PEP 728", + "url": "https://peps.python.org/pep-0728/" + } + ] }, { "code": "typeddicts_inheritance", @@ -4276,7 +6882,7 @@ "body": [ { "type": "text", - "html": "PEP 589 and the typing spec place constraints on TypedDict inheritance:" + "html": "PEP 589 and the typing spec place constraints on TypedDict inheritance:" }, { "type": "text", @@ -4284,7 +6890,7 @@ }, { "type": "text", - "html": "2. A TypedDict subclass cannot change the type of a field declared in a parent TypedDict class. PEP 705 refines this for the ReadOnly, Required, and NotRequired qualifiers: - A writable (non-ReadOnly) item may not be redeclared ReadOnly. - A required item may not be redeclared as not-required. - A writable item's value type is invariant; a ReadOnly item's value type may be narrowed to a subtype." + "html": "2. A TypedDict subclass cannot change the type of a field declared in a parent TypedDict class. PEP 705 refines this for the ReadOnly, Required, and NotRequired qualifiers: - A writable (non-ReadOnly) item may not be redeclared ReadOnly. - A required item may not be redeclared as not-required. - A writable item's value type is invariant; a ReadOnly item's value type may be narrowed to a subtype." }, { "type": "text", @@ -4292,7 +6898,29 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_inheritance" + "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_inheritance", + "references": [ + { + "label": "Typing spec: Typed dictionaries", + "url": "https://typing.python.org/en/latest/spec/typeddict.html" + }, + { + "label": "PEP 589", + "url": "https://peps.python.org/pep-0589/" + }, + { + "label": "PEP 655", + "url": "https://peps.python.org/pep-0655/" + }, + { + "label": "PEP 705", + "url": "https://peps.python.org/pep-0705/" + }, + { + "label": "PEP 728", + "url": "https://peps.python.org/pep-0728/" + } + ] }, { "code": "typeddicts_operations", @@ -4307,7 +6935,7 @@ "body": [ { "type": "text", - "html": "PEP 589 defines TypedDict as a typed dict with a fixed set of keys and associated types. This rule detects:" + "html": "PEP 589 defines TypedDict as a typed dict with a fixed set of keys and associated types. This rule detects:" }, { "type": "text", @@ -4320,7 +6948,29 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_operations" + "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_operations", + "references": [ + { + "label": "Typing spec: Typed dictionaries", + "url": "https://typing.python.org/en/latest/spec/typeddict.html" + }, + { + "label": "PEP 589", + "url": "https://peps.python.org/pep-0589/" + }, + { + "label": "PEP 655", + "url": "https://peps.python.org/pep-0655/" + }, + { + "label": "PEP 705", + "url": "https://peps.python.org/pep-0705/" + }, + { + "label": "PEP 728", + "url": "https://peps.python.org/pep-0728/" + } + ] }, { "code": "typeddicts_readonly", @@ -4344,7 +6994,29 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_readonly" + "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_readonly", + "references": [ + { + "label": "Typing spec: Typed dictionaries", + "url": "https://typing.python.org/en/latest/spec/typeddict.html" + }, + { + "label": "PEP 589", + "url": "https://peps.python.org/pep-0589/" + }, + { + "label": "PEP 655", + "url": "https://peps.python.org/pep-0655/" + }, + { + "label": "PEP 705", + "url": "https://peps.python.org/pep-0705/" + }, + { + "label": "PEP 728", + "url": "https://peps.python.org/pep-0728/" + } + ] }, { "code": "typeddicts_required", @@ -4359,7 +7031,7 @@ "body": [ { "type": "text", - "html": "PEP 655 and the typing spec restrict RequiredT and NotRequiredT to:" + "html": "PEP 655 and the typing spec restrict RequiredT and NotRequiredT to:" }, { "type": "text", @@ -4380,7 +7052,29 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_required" + "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_required", + "references": [ + { + "label": "Typing spec: Typed dictionaries", + "url": "https://typing.python.org/en/latest/spec/typeddict.html" + }, + { + "label": "PEP 589", + "url": "https://peps.python.org/pep-0589/" + }, + { + "label": "PEP 655", + "url": "https://peps.python.org/pep-0655/" + }, + { + "label": "PEP 705", + "url": "https://peps.python.org/pep-0705/" + }, + { + "label": "PEP 728", + "url": "https://peps.python.org/pep-0728/" + } + ] }, { "code": "typeddicts_usage", @@ -4395,7 +7089,7 @@ "body": [ { "type": "text", - "html": "PEP 589 defines constraints on what you can do with TypedDict type objects at runtime:" + "html": "PEP 589 defines constraints on what you can do with TypedDict type objects at runtime:" }, { "type": "text", @@ -4408,7 +7102,29 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_usage" + "docsUrl": "https://www.basilisk-python.dev/errors/typeddicts_usage", + "references": [ + { + "label": "Typing spec: Typed dictionaries", + "url": "https://typing.python.org/en/latest/spec/typeddict.html" + }, + { + "label": "PEP 589", + "url": "https://peps.python.org/pep-0589/" + }, + { + "label": "PEP 655", + "url": "https://peps.python.org/pep-0655/" + }, + { + "label": "PEP 705", + "url": "https://peps.python.org/pep-0705/" + }, + { + "label": "PEP 728", + "url": "https://peps.python.org/pep-0728/" + } + ] }, { "code": "version_target_syntax", @@ -4418,11 +7134,11 @@ "pep" ], "summary": "PEP 695 syntax used below the configured target version", - "summaryHtml": "PEP 695 syntax used below the configured target version", + "summaryHtml": "PEP 695 syntax used below the configured target version", "body": [ { "type": "text", - "html": "type X = ... aliases and class FooT / def fT() type-parameter lists are Python 3.12+ syntax (PEP 695). When the configured python_version targets anything older, the file cannot even be parsed by the target interpreter, so this fires as an error (issue #93)." + "html": "type X = ... aliases and class FooT / def fT() type-parameter lists are Python 3.12+ syntax (PEP 695). When the configured python_version targets anything older, the file cannot even be parsed by the target interpreter, so this fires as an error (issue #93)." }, { "type": "code", @@ -4431,6 +7147,16 @@ } ], "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/version_target_syntax" + "docsUrl": "https://www.basilisk-python.dev/errors/version_target_syntax", + "references": [ + { + "label": "Typing spec: Generics", + "url": "https://typing.python.org/en/latest/spec/generics.html" + }, + { + "label": "PEP 695", + "url": "https://peps.python.org/pep-0695/" + } + ] } ] diff --git a/website/src/_data/site.json b/website/src/_data/site.json index bb0bed15..00beb5ec 100644 --- a/website/src/_data/site.json +++ b/website/src/_data/site.json @@ -3,7 +3,7 @@ "title": "Basilisk: The Only 100% PEP-Conformant Python Language Server", "description": "Open-source Python language server with a perfect official typing-conformance score, plus refactoring, debugging, profiling, and editor extensions.", "url": "https://www.basilisk-python.dev", - "keywords": "basilisk, python type checker, 100% pep conformance, python/typing conformance results, python language server, lsp, strict typing, rust, vs code, cursor, zed, neovim, open source, autocomplete, refactoring", + "keywords": "basilisk, best python type checker, python type checker, 100% pep conformance, python/typing conformance results, python language server, lsp, strict typing, rust, vs code, cursor, zed, neovim, open source, autocomplete, refactoring", "themeColor": "#e8500a", "stylesheet": "/assets/css/styles.css", "github": "https://github.com/Nimblesite/Basilisk", diff --git a/website/src/docs/configuration.md b/website/src/docs/configuration.md index 75cdbb00..da280116 100644 --- a/website/src/docs/configuration.md +++ b/website/src/docs/configuration.md @@ -47,9 +47,10 @@ anywhere on the walk: - The target Python version is resolved from your project files: `.python-version`, then the `[project].requires-python` lower bound, then the `uv.lock` `requires-python` lower bound. -- Standard-library stubs are acquired at runtime from the latest - [python/typeshed](https://github.com/python/typeshed) commit, with a - bundled snapshot as the offline fallback — see +- Standard-library stubs come from the bundled + [python/typeshed](https://github.com/python/typeshed) snapshot compiled into + the binary — fully offline, with an `UNPINNED` advisory until you pin a + commit — see [Standard-library stubs](#standard-library-stubs-typeshed). > **In an editor, that state is seeded once — the CLI never writes @@ -320,15 +321,32 @@ Basilisk selects exactly **one** step-3 source: | Source | What it resolves to | | --- | --- | | Custom folder | your `typeshed-path` directory, verbatim | -| Pinned commit | the `typeshed-commit` SHA, downloaded as a verified archive (fails closed if unavailable) | -| Latest _(default)_ | the current `python/typeshed@main` commit, resolved once per run/session; if it cannot be resolved, the compiled-in bundled snapshot, with `UNPINNED` and `DOWNLOAD FAILED` warnings | - -Latest keeps you fresh but is not reproducible day-to-day — the editor's -Server Info panel reports it as an `UNPINNED` row, and choosing **Pinned -commit** in the Configuration Editor writes the resolved SHA as -`typeshed-commit` (and clears any `typeshed-path`). -Downloaded archives pass safety, shape, license, and content-verification -gates before activation, and are cached as immutable ZIPs. Full detail: +| Pinned commit | the `typeshed-commit` SHA, verified offline against the on-disk store (fails closed if that commit is not on this machine) | + +Resolution is **fully offline**: `basilisk check`, `basilisk analyze`, and the +LSP never download anything. A pin does exactly one thing — it verifies that +the typeshed tree on disk matches the SHA of that commit. If the pinned commit +has not been downloaded to this machine, the check tanks hard with a +`NO SOURCE` error (exit code 3) naming the recovery command; it never silently +substitutes another source. + +Typeshed bytes arrive on a machine only through explicit download actions, +which live entirely outside the checker. There are exactly two download +contracts, each reachable from the Configuration Editor and from the CLI: + +- **Download latest** — the Configuration Editor button (offered whenever no + download is running), or `basilisk typeshed download` with no `--commit`. + Downloads the current `python/typeshed@main` commit and writes the resolved + SHA as your `typeshed-commit` pin (clearing any `typeshed-path`). +- **Download pinned** — the Configuration Editor button on the `NO SOURCE` row + (offered when the pinned commit is not on this machine), or + `basilisk typeshed download --commit `. Materialises that exact, + already-configured pin into the store and writes no configuration. + +Every download passes safety, shape, license, and content-verification gates +before anything lands in the content-addressed store; entries are immutable +once written, and every later resolution re-hashes the stored tree against +the pin's commit object — offline. Full detail: [`STUBRES-TYPESHED`](https://github.com/Nimblesite/Basilisk/blob/main/docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED). The bundled snapshot is compiled into the binary, so stdlib types work with no @@ -337,23 +355,20 @@ is the **complete set of typeshed `stdlib/` `.pyi` stubs** (third-party `stubs/` and typeshed's own non-stub `stdlib/` files excluded) at commit [`83c2518`](https://github.com/python/typeshed/tree/83c2518a9e6abbda0c44592c3483de459198f887/stdlib): 752 `.pyi` files plus `stdlib/VERSIONS` and `LICENSE`, ~2.85 MB uncompressed. -It is a fallback, not a pin — when it is in use Basilisk always says so. +When `typeshed-commit` is unset, the bundled commit is the effective pin and +the editor's Server Info panel shows an `UNPINNED` advisory; pinning any +commit explicitly — the bundled `83c2518…` included — clears it. | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `typeshed-commit` | full 40-char SHA | _(unset — Latest)_ | Exact `python/typeshed` commit to use. A pin **fails closed** — it never silently substitutes another commit. Abbreviated SHAs are rejected. | -| `typeshed-url` | URL template | GitHub codeload | HTTPS archive mirror containing exactly one `{sha}` placeholder. A mirror cannot resolve Latest. | -| `typeshed-cache-path` | path | OS cache dir | Where gate-accepted ZIPs are cached. | -| `typeshed-cache` | bool | `true` | Reuse re-hashed cached ZIPs until explicitly evicted for an exact pin, or for 24 hours with Latest; `false` downloads, validates, and discards every run. | -| `typeshed-verify` | bool | `true` | Content-attest the archive against the trusted git tree; `false` reports `UNVERIFIED` and never bypasses the safety, shape, or license gates. | -| `typeshed-path` | path | _(unset)_ | Your own stdlib stub tree — disables both download and the bundled snapshot. | - -Two of these have one-off CLI equivalents on `basilisk check` and -`basilisk analyze`, for a single CI run you don't want to encode in the file: -`--no-typeshed-cache` (equivalent to `typeshed-cache = false`) and -`--no-typeshed-verification` (equivalent to `typeshed-verify = false`). -Unrelated despite the name: `basilisk stubs` generates stubs for untyped -**third-party** packages and has nothing to do with typeshed acquisition. +| `typeshed-commit` | full 40-char SHA | _(unset — the bundled commit, with an `UNPINNED` advisory)_ | Exact `python/typeshed` commit the on-disk tree must match. A pin **fails closed** — it never silently substitutes another commit. Abbreviated SHAs are rejected. | +| `typeshed-store-path` | path | OS cache dir | Root of the verified, content-addressed store that `basilisk typeshed download` writes into and pins resolve from. | +| `typeshed-path` | path | _(unset)_ | Your own stdlib stub tree — replaces the store and the bundled snapshot entirely. | + +That is the whole surface: there are no download-policy keys and no +download-related CLI flags on `check` or `analyze` — downloading is never part +of a check run. Unrelated despite the name: `basilisk stubs` generates stubs +for untyped **third-party** packages and has nothing to do with typeshed. `typeshed-path` and `typeshed-commit` are **one source selection**: a nested config file that sets either replaces the inherited choice as a unit, never @@ -362,7 +377,7 @@ mixing a path from one file with a pin from another. ### `typeshed-path` **Type:** `string` -**Default:** _(unset — typeshed is acquired at runtime as above)_ +**Default:** _(unset — the pinned or bundled commit is used, as above)_ **Example:** `"vendor/typeshed"` **Spec:** [`STUBRES-CUSTOM-TYPESHED`](https://github.com/Nimblesite/Basilisk/blob/main/docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CUSTOM-TYPESHED) @@ -371,7 +386,7 @@ standard-library stubs. When set, this directory becomes the **canonical source for standard-library types** — the typing spec states that type checkers "SHOULD use this as the canonical source for standard-library types in this step." A stdlib module absent from the directory falls through to the -remaining resolution steps — it is **not** rescued by a downloaded or bundled +remaining resolution steps — it is **not** rescued by the store or the bundled typeshed. ## How to use a custom typeshed diff --git a/website/src/docs/conformance.md b/website/src/docs/conformance.md index 31978927..c80a9f7b 100644 --- a/website/src/docs/conformance.md +++ b/website/src/docs/conformance.md @@ -2,7 +2,7 @@ layout: layouts/docs.njk title: "Basilisk Scores 100% on the Official Python Typing Conformance Suite" description: "Basilisk is the only Python type checker with a perfect 100% score — published on the official python/typing conformance results page, ahead of Pyright, mypy, Pyrefly and ty. Here's the proof and how it's measured." -keywords: pep conformance, python typing conformance results, 100% conformant type checker, basilisk conformance score, python/typing results +keywords: pep conformance, python typing conformance results, 100% conformant type checker, best python type checker, basilisk conformance score, python/typing results date: 2026-06-23 dateModified: 2026-07-07 author: The Basilisk Project diff --git a/website/src/docs/index.md b/website/src/docs/index.md index e4640636..ad3c5841 100644 --- a/website/src/docs/index.md +++ b/website/src/docs/index.md @@ -2,7 +2,7 @@ layout: layouts/docs.njk title: "Basilisk: The Only 100% PEP-Conformant Python Language Server" description: "Install, configure, and use Basilisk: an open-source Python type checker and language server with refactoring, debugging, profiling, and editor integrations." -keywords: basilisk, python, language server, lsp, type checker, vs code, cursor, zed, neovim, strict, rust +keywords: basilisk, best python type checker, python, language server, lsp, type checker, vs code, cursor, zed, neovim, strict, rust date: 2026-02-28 dateModified: 2026-03-31 author: The Basilisk Project @@ -15,7 +15,7 @@ eleventyNavigation: Basilisk is a **complete, open-source Python language server**. Everything you rely on a modern Python extension for — autocomplete, go-to-definition, hover information, refactoring, diagnostics, integrated debugging, profiling — Basilisk does too, fully open source and conformant to the Python typing spec by default. -It is also the **only Python type checker with a perfect 100% score** on the [official `python/typing` conformance results]({{ conformanceOfficial.snapshot.source }}) — published on the Python typing repository's own leaderboard, ahead of Pyright, mypy, Pyrefly and ty. See [how we measure it](/docs/conformance/). +It is also the **only Python type checker with a perfect 100% score** on the [official `python/typing` conformance results]({{ conformanceOfficial.snapshot.source }}) — published on the Python typing repository's own leaderboard, ahead of Pyright, mypy, Pyrefly and ty. See [how we measure it](/docs/conformance/), or weigh up the [best Python type checker](/docs/comparison/) for your project in the comparison. It is not just a type checker. It is a feature-complete LSP with first-class extensions for **VS Code**, **Cursor**, **Windsurf**, **Zed**, and **Neovim** — plus any other editor that speaks the Language Server Protocol. JetBrains support is planned. No proprietary extension, no Node.js — a single Rust binary, the same experience in every editor. @@ -35,7 +35,7 @@ Basilisk takes a different position. Its default *is* the typing spec — full P - An **integrated debugger** — press F5 to debug Python with breakpoints, stepping, variable inspection, and watch expressions, all brokered through the Basilisk LSP - An **integrated profiler** — sampling CPU profiler with inline heatmap annotations, flame graphs, memory leak detection, and reference graph visualization, all inside your editor - A **PEP-conformant type checker by default** — the core spec rule set out of the box, with opt-in Basilisk rules for checking stricter than the spec -- **Standard-library types out of the box** — typeshed stubs are acquired at runtime from `python/typeshed@main`, verified, and cached; a complete typeshed `stdlib/` tree is compiled into the binary as the offline fallback, so stdlib types work with no network and no configuration +- **Standard-library types out of the box** — a complete typeshed `stdlib/` tree is compiled into the binary and checking never downloads anything, so stdlib types work with no network and no configuration; pin an exact `python/typeshed` commit and it is verified offline against your local store - A **CLI tool** for CI integration — exits with code 1 when errors are found - A **migration assistant** that reads your existing `pyrightconfig.json` or `mypy.ini` - **uv integration** — workspace detection, lock file parsing, and package management commands @@ -84,12 +84,14 @@ Basilisk is under **active development** — the core checker, LSP server, and e ## Architecture -Basilisk is a Cargo workspace with 18 Rust crates, each owning one layer of the system: +Basilisk is a Cargo workspace with 19 Rust crates, each owning one layer of the system: | Layer | Crates | |-------|--------| | **Analysis pipeline** | `basilisk-parser` → `basilisk-resolver` → `basilisk-checker` → `basilisk-cli` | -| **LSP & infrastructure** | `basilisk-lsp`, `basilisk-db`, `basilisk-config`, `basilisk-stubs`, `basilisk-uv`, `basilisk-common`, `basilisk-test-utils`, `basilisk-profiler-helper` | +| **LSP & infrastructure** | `basilisk-lsp`, `basilisk-db`, `basilisk-config`, `basilisk-stubs`, `basilisk-uv`, `basilisk-common`, `basilisk-buildinfo`, `basilisk-profiler-helper`, `basilisk-profiler-protocol` | +| **Typeshed downloads** | `basilisk-typeshed-fetch` — the workspace's only HTTP client; it downloads typeshed on an explicit user action, strictly segregated from checking | +| **Test infrastructure** | `basilisk-test-utils`, `basilisk-test-macros` | | **Editor extensions** | VS Code (`vscode-extension`), Neovim (`basilisk.nvim`), Zed (`basilisk-zed`) | | **Future** | `basilisk-mojo` (ownership), `basilisk-compiler` (native), WASM plugins | diff --git a/website/src/docs/installation.md b/website/src/docs/installation.md index 89a10696..ed0f54ee 100644 --- a/website/src/docs/installation.md +++ b/website/src/docs/installation.md @@ -2,7 +2,7 @@ layout: layouts/docs.njk title: "Install Basilisk — VS Code, Cursor, Zed, Neovim, or CLI" description: "Install the Basilisk Python language server for your editor — VS Code, Cursor, Windsurf, Zed, or Neovim — or as a standalone CLI via PyPI (uv tool install or pipx), Homebrew, Scoop, or pre-built binaries. Single Rust binary, no runtime dependencies." -keywords: basilisk, install, vs code, cursor, windsurf, zed, neovim, pypi, pip, uv, pipx, homebrew, scoop, open vsx, python language server, rust +keywords: basilisk, install, best python type checker, vs code, cursor, windsurf, zed, neovim, pypi, pip, uv, pipx, homebrew, scoop, open vsx, python language server, rust date: 2026-02-28 dateModified: 2026-07-19 author: The Basilisk Project diff --git a/website/src/docs/quick-start.md b/website/src/docs/quick-start.md index b0ca5ac2..9635a0a0 100644 --- a/website/src/docs/quick-start.md +++ b/website/src/docs/quick-start.md @@ -2,7 +2,7 @@ layout: layouts/docs.njk title: "Quick Start — Type-Check Your First File in 5 Minutes" description: "Get started with Basilisk in 5 minutes. Install the VS Code extension, run your first type check, and see PEP-conformant Python diagnostics in action." -keywords: basilisk, quick start, python language server, type checking, tutorial, vs code +keywords: basilisk, quick start, best python type checker, python language server, type checking, tutorial, vs code date: 2026-02-28 dateModified: 2026-07-14 author: The Basilisk Project @@ -240,15 +240,16 @@ result: Any = legacy_sdk_call() # type: ignore[returns_compatibility] Suppressions without reasons are themselves flagged. This is intentional: if you need to suppress a diagnostic, you should be able to explain why. -## Step 8 — Check stats +## Step 8 — Check type coverage -Get a type coverage report for your project: +Type coverage is reported live in the editor. Open the **Basilisk** icon in the +activity bar and expand **Module Explorer**: every package and file carries a +coverage bar and percentage — the share of its symbols that are annotated — +alongside its error and warning counts, and icons are tinted by coverage so the +untyped corners of a project stand out at a glance. Selecting a module opens it. -```bash -basilisk stats src/ -``` - -Output includes: total functions, typed functions, type coverage percentage, files with no annotations. +Coverage aggregates up the tree, so a package shows the combined figure for +everything beneath it. ## Step 9 — Profile a running script diff --git a/website/src/errors/error.njk b/website/src/errors/error.njk index 6d581c25..180fd385 100644 --- a/website/src/errors/error.njk +++ b/website/src/errors/error.njk @@ -50,6 +50,17 @@ eleventyComputed: alt="basilisk check output reporting {{ rule.code }} — {{ rule.summary }}" loading="lazy"> {% endif %} +{# Implements [WEBSITE-ERROR-PAGES-REFERENCES]: every page deep-links the + authoritative typing-spec chapter and canonical PEP(s) for its rule. #} +{% if rule.references.length %} +

Canonical documentation

+ +{% endif %} +

How to handle it

{% if rule.provenance == "basilisk" %}

diff --git a/website/src/index.njk b/website/src/index.njk index 23b11a99..f3d84cc6 100644 --- a/website/src/index.njk +++ b/website/src/index.njk @@ -1,7 +1,8 @@ --- layout: layouts/base.njk title: "Basilisk — The Only 100% PEP-Conformant Python Language Server" -description: "Open-source Python language server with a perfect official typing-conformance score, plus refactoring, debugging, profiling, and editor extensions." +description: "Looking for the best Python type checker? Basilisk is the only one scoring 100% on the official typing-conformance suite — plus refactoring, debugging, profiling, and editor extensions." +keywords: "best python type checker, basilisk, python type checker, python language server, 100% pep conformance, python/typing conformance results, lsp, strict typing, rust, vs code, cursor, zed, neovim" permalink: / --- @@ -30,7 +31,9 @@ permalink: / type checker, language server, debugger, profiler — autocomplete, go-to-definition, hover, refactoring, diagnostics and integrated debugging in VS Code, Cursor, Zed, and Neovim, without the proprietary - lock-in. Strict by default. + lock-in. Strict by default. Weighing up the + best Python type checker for your + codebase? Start with the scoreboard.

diff --git a/website/src/zh/docs/configuration.md b/website/src/zh/docs/configuration.md index f690a7fd..074cbc9c 100644 --- a/website/src/zh/docs/configuration.md +++ b/website/src/zh/docs/configuration.md @@ -39,9 +39,10 @@ Basilisk 完全不需要配置文件。当遍历路径上任何地方都没有 ` - 目标 Python 版本从项目文件解析:`.python-version`,然后是 `[project].requires-python` 下界,然后是 `uv.lock` 的 `requires-python` 下界。 -- 标准库存根在运行时从最新的 - [python/typeshed](https://github.com/python/typeshed) 提交获取,离线时 - 回退到内置快照——见[标准库存根](#标准库存根-typeshed)。 +- 标准库存根来自编译进二进制的 + [python/typeshed](https://github.com/python/typeshed) 内置快照——完全 + 离线,在你固定某个提交之前会附带 `UNPINNED` + 提示——见[标准库存根](#标准库存根-typeshed)。 > **在编辑器中,这一状态会被一次性写入种子配置——CLI 从不写配置,但 LSP 会。** > 当某个工作区根目录的遍历找不到任何 `[tool.basilisk]` 表时,语言服务器会在 @@ -290,13 +291,26 @@ exclude = [ | 模式 | 生效来源 | | --- | --- | | 自定义文件夹 | 你的 `typeshed-path` 目录,原样使用 | -| 精确提交 | `typeshed-commit` 指定的 SHA,以经验证的归档下载(不可用时失败关闭) | -| 最新(默认) | 当前的 `python/typeshed@main` 提交,每次运行/会话解析一次;无法解析时回退到编译进二进制的内置快照,并给出 `UNPINNED` 与 `DOWNLOAD FAILED` 两条警告 | - -"最新"模式保持新鲜但无法逐日复现——编辑器的 Server Info 面板会将其报告为 -`UNPINNED` 行;在配置编辑器中选择 **Pinned commit**(固定提交)来源会把解析出的 -SHA 写入 `typeshed-commit`,并清除任何 `typeshed-path`。下载的归档在激活前需通过安全、结构、许可证与内容验证 -关卡,并以不可变 ZIP 缓存。完整细节: +| 固定提交 | `typeshed-commit` 指定的 SHA,离线对照磁盘上的存储库校验(该提交不在本机时失败关闭) | + +解析**完全离线**:`basilisk check`、`basilisk analyze` 和 LSP 绝不下载任何 +东西。固定提交只做一件事——校验磁盘上的 typeshed 树与该提交的 SHA 一致。 +如果固定的提交尚未下载到本机,检查会以 `NO SOURCE` 错误硬失败(退出码 +3),并给出恢复命令;绝不静默替换为其他来源。 + +typeshed 的字节只能通过显式下载操作到达机器,而这些操作完全位于检查器 +之外: + +- 配置编辑器的 **Download latest**(下载最新)按钮:下载当前的 + `python/typeshed@main` 提交,并把解析出的 SHA 写入你的 + `typeshed-commit` 固定项(同时清除任何 `typeshed-path`); +- `basilisk typeshed download [--commit ]`——不带 `--commit` 时与按钮 + 行为相同;带 `--commit` 时将该已配置的精确提交物化到存储库,不写入任何 + 配置。 + +每次下载在任何字节落入内容寻址存储库之前,都要通过安全、结构、许可证与 +内容验证关卡;条目写入后不可变,之后的每次解析都会离线地将存储的树重新 +哈希并对照该固定提交的提交对象。完整细节: [`STUBRES-TYPESHED`](https://github.com/Nimblesite/Basilisk/blob/main/docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED)。 内置快照被编译进二进制文件,因此完全无需网络也能获得标准库类型——在飞机上、 @@ -304,23 +318,19 @@ SHA 写入 `typeshed-commit`,并清除任何 `typeshed-path`。下载的归档 [`83c2518`](https://github.com/python/typeshed/tree/83c2518a9e6abbda0c44592c3483de459198f887/stdlib) 处**完整的 typeshed `stdlib/` `.pyi` 存根集合**(不含第三方 `stubs/`,也不含 typeshed 自身在 `stdlib/` 下的非存根文件):752 个 `.pyi` 文件,外加 -`stdlib/VERSIONS` 与 `LICENSE`,未压缩约 2.85 MB。它是回退方案而非 -固定来源——一旦启用,Basilisk 一定会明确告知。 +`stdlib/VERSIONS` 与 `LICENSE`,未压缩约 2.85 MB。未设置 `typeshed-commit` +时,内置提交即为生效的固定项,编辑器的 Server Info 面板会显示 `UNPINNED` +提示;显式固定任意提交——包括内置的 `83c2518…`——即可清除该提示。 | 键 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | -| `typeshed-commit` | 完整 40 位 SHA | _(未设置——最新)_ | 要使用的精确 `python/typeshed` 提交。固定后**失败关闭**——绝不静默替换为其他提交。缩写 SHA 会被拒绝。 | -| `typeshed-url` | URL 模板 | GitHub codeload | 包含恰好一个 `{sha}` 占位符的 HTTPS 归档镜像。镜像无法解析"最新"。 | -| `typeshed-cache-path` | 路径 | 操作系统缓存目录 | 通过关卡的 ZIP 的缓存位置。 | -| `typeshed-cache` | bool | `true` | 24 小时内复用经重新哈希的缓存 ZIP;`false` 每次运行都下载、验证并丢弃。 | -| `typeshed-verify` | bool | `true` | 对归档做内容证明,与受信任的 git 树比对;`false` 报告 `UNVERIFIED`,且绝不绕过安全、结构或许可证关卡。 | -| `typeshed-path` | 路径 | _(未设置)_ | 你自己的标准库存根树——同时禁用下载与内置快照。 | - -其中两项在 `basilisk check` 与 `basilisk analyze` 上有一次性的命令行等价开关, -适用于不想写进配置文件的单次 CI 运行:`--no-typeshed-cache`(等价于 -`typeshed-cache = false`)与 `--no-typeshed-verification`(等价于 -`typeshed-verify = false`)。另请注意:`basilisk stubs` 用于为**第三方**未加 -类型的包生成存根,与 typeshed 获取无关。 +| `typeshed-commit` | 完整 40 位 SHA | _(未设置——内置提交,附 `UNPINNED` 提示)_ | 磁盘上的树必须匹配的精确 `python/typeshed` 提交。固定后**失败关闭**——绝不静默替换为其他提交。缩写 SHA 会被拒绝。 | +| `typeshed-store-path` | 路径 | 操作系统缓存目录 | 经验证的内容寻址存储库根目录:`basilisk typeshed download` 写入这里,固定提交从这里解析。 | +| `typeshed-path` | 路径 | _(未设置)_ | 你自己的标准库存根树——完全取代存储库与内置快照。 | + +这就是全部配置面:不存在任何下载策略键,`check` 与 `analyze` 也没有任何 +与下载相关的命令行开关——下载绝不是检查运行的一部分。另请注意: +`basilisk stubs` 用于为**第三方**未加类型的包生成存根,与 typeshed 无关。 `typeshed-path` 与 `typeshed-commit` 是**同一个来源选择**:设置了其中任一 键的嵌套配置文件会将继承的选择作为整体替换,绝不会把一个文件的路径和 @@ -329,7 +339,7 @@ typeshed 自身在 `stdlib/` 下的非存根文件):752 个 `.pyi` 文件, ### `typeshed-path` **类型:** `string` -**默认值:** _(未设置——按上文在运行时获取 typeshed)_ +**默认值:** _(未设置——按上文使用固定或内置提交)_ **示例:** `"vendor/typeshed"` **规范:** [`STUBRES-CUSTOM-TYPESHED`](https://github.com/Nimblesite/Basilisk/blob/main/docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CUSTOM-TYPESHED) @@ -337,7 +347,7 @@ typeshed 自身在 `stdlib/` 下的非存根文件):752 个 `.pyi` 文件, 将成为**标准库类型的规范来源**——typing 规范指出类型检查器"SHOULD use this as the canonical source for standard-library types in this step" (应将其用作此步骤中标准库类型的规范来源)。目录中缺失的标准库模块将继续 -进入后续的解析步骤——**不会**由下载的或内置的 typeshed 来兜底。 +进入后续的解析步骤——**不会**由存储库或内置的 typeshed 来兜底。 ## 如何使用自定义 typeshed diff --git a/website/src/zh/docs/debugging.md b/website/src/zh/docs/debugging.md index f52a530b..6fd9caff 100644 --- a/website/src/zh/docs/debugging.md +++ b/website/src/zh/docs/debugging.md @@ -165,6 +165,50 @@ except FileNotFoundError as exc: - `exc.__cause__` — 链式异常(如果 `raise ... from ...`) - 异常子类上的自定义属性 +### 在引发异常时中断 + +使用 VS Code 的**断点**面板(调试侧边栏底部)配置异常断点: +- 勾选**已引发的异常(Raised Exceptions)**,在任何异常被引发时中断 +- 勾选**未捕获的异常(Uncaught Exceptions)**,仅在未处理的异常上中断 + +## 附加模式 + +要调试正在运行的进程,请在您的脚本中启动 debugpy: + +```python +import debugpy +debugpy.listen(5678) +debugpy.wait_for_client() # 暂停直到 VS Code 连接 +``` + +然后使用附加配置: + +```json +{ + "name": "Attach (Basilisk)", + "type": "basilisk-debug", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + } +} +``` + +这对于调试 Web 服务器(Django、Flask、FastAPI)和长时间运行的进程很有用。 + +## 验证调试链 + +要确认调试确实经过 Basilisk(而不是其他扩展),请打开 **Basilisk** 输出通道(**查看 > 输出 > Basilisk**)。启动调试会话时,您将看到: + +``` +[Basilisk Debug] createDebugAdapterDescriptor called — type=basilisk-debug, request=launch, program=/path/to/file.py +[Basilisk Debug] Requesting LSP to spawn debugpy (python: auto-detect)... +[Basilisk Debug] LSP spawned debugpy → connecting VS Code DAP client to localhost:57356 (session: dbg-11a1cb40) +``` + +这些消息证明了完整的链路:VS Code → Basilisk 扩展 → Basilisk LSP(Rust)→ debugpy → 您的代码。 + ## 故障排除 ### debugpy 未安装 @@ -203,6 +247,77 @@ Basilisk Debug: No Python interpreter found. 使用 `integratedTerminal` 时,输出进入终端选项卡。 +### 调试启动时出现 ECONNREFUSED + +如果您看到 `connect ECONNREFUSED 127.0.0.1:`,则 debugpy 进程可能未能启动。请检查: + +1. 是否安装了 debugpy?`python -m debugpy --version` +2. Python 路径是否正确?检查 `basilisk.python` 设置 +3. 查看 Basilisk 输出通道以获取错误详情 + +## 在 Zed 中调试 + +Basilisk 的调试器通过同样的 DAP(调试适配器协议)机制在 Zed 中工作。Zed 扩展注册 `basilisk-debug` 调试适配器,它通过 TCP 连接到 debugpy。 + +### 先决条件 + +在您的 Python 环境中安装 debugpy: + +```bash +pip install debugpy +``` + +### 启动调试会话 + +1. 在 Zed 中打开一个 Python 文件 +2. 通过单击装订线设置断点 +3. 从命令面板或调试面板启动调试 + +### 控制台输出 + +默认情况下,Basilisk 在 Zed 中使用 **`integratedTerminal`** 模式。这意味着程序输出(来自 `print()` 等)出现在**终端**选项卡中,而不是控制台选项卡中。 + +如果您希望输出出现在调试控制台中,请在调试配置中覆盖 `console` 设置: + +```json +{ + "program": "${file}", + "console": "internalConsole" +} +``` + +### Zed 中的控制台模式 + +| 模式 | 输出出现的位置 | 何时使用 | +|---|---|---| +| `integratedTerminal`(默认) | **终端**选项卡 | 需要 stdin 输入的程序,或当您希望输出与调试控件分开时 | +| `internalConsole` | **控制台**选项卡 | 当您希望输出与调试表达式求值放在一起时 | + +### 调试配置 + +Zed 扩展在调试启动配置中支持这些选项: + +| 选项 | 类型 | 默认值 | 描述 | +|---|---|---|---| +| `program` | `string` | — | 要调试的 Python 文件路径(必填) | +| `args` | `string[]` | `[]` | 传递给程序的命令行参数 | +| `cwd` | `string` | 工作区根目录 | 工作目录 | +| `python` | `string` | 自动检测 | Python 解释器路径 | +| `justMyCode` | `boolean` | `true` | 只调试您的代码,跳过库内部 | +| `stopOnEntry` | `boolean` | `false` | 在程序的第一行中断 | +| `console` | `string` | `integratedTerminal` | 显示输出的位置:`integratedTerminal` 或 `internalConsole` | + +### 在 Zed 中验证调试链 + +查看 Zed 日志(`Cmd+Shift+P` → **zed: open log**)以确认调试适配器连接的消息: + +``` +[basilisk-debug] Spawning debugpy on port 57356 +[basilisk-debug] DAP client connected +``` + +这证实了:Zed → Basilisk 扩展 → Basilisk LSP(Rust)→ debugpy → 您的代码。 + ## 架构 ``` diff --git a/website/src/zh/docs/index.md b/website/src/zh/docs/index.md index d8237bf9..cc3810c5 100644 --- a/website/src/zh/docs/index.md +++ b/website/src/zh/docs/index.md @@ -30,7 +30,7 @@ Basilisk 采取不同的立场。它的默认*就是*类型规范——开箱即 - **集成调试器**——按 F5 调试 Python,支持断点、单步执行、变量检查和监视表达式,全部通过 Basilisk LSP 代理 - **集成性能分析器**——采样式 CPU 分析器,具有内联热图注解、火焰图、内存泄漏检测和引用图可视化,全部在您的编辑器内 - **默认符合 PEP 规范的类型检查器**——开箱即用核心规范规则集,并提供可选的 Basilisk 规则以实现比规范更严格的检查 -- **开箱即得标准库类型**——运行时从 `python/typeshed@main` 获取并验证标准库存根后缓存;二进制文件中还编译进了一份完整的 typeshed `stdlib/` 树作为离线回退,因此无需网络、无需配置即可获得标准库类型 +- **开箱即得标准库类型**——二进制文件中编译进了一份完整的 typeshed `stdlib/` 树,且检查从不下载任何东西,因此无需网络、无需配置即可获得标准库类型;固定某个确切的 `python/typeshed` 提交后,它会离线对照本地存储库校验 - **用于 CI 集成的 CLI 工具**——发现错误时以代码 1 退出 - **迁移助手**,读取您现有的 `pyrightconfig.json` 或 `mypy.ini` - **uv 集成**——工作区检测、锁文件解析和包管理命令 @@ -75,12 +75,14 @@ Basilisk 正在**积极开发中**——核心检查器、LSP 服务器和编辑 ## 架构 -Basilisk 是一个 Cargo 工作区,包含 18 个 Rust crate,每个拥有系统的一层: +Basilisk 是一个 Cargo 工作区,包含 19 个 Rust crate,每个拥有系统的一层: | 层 | Crate | |-------|--------| | **分析管道** | `basilisk-parser` → `basilisk-resolver` → `basilisk-checker` → `basilisk-cli` | -| **LSP & 基础设施** | `basilisk-lsp`, `basilisk-db`, `basilisk-config`, `basilisk-stubs`, `basilisk-uv`, `basilisk-common`, `basilisk-test-utils`, `basilisk-profiler-helper` | +| **LSP & 基础设施** | `basilisk-lsp`, `basilisk-db`, `basilisk-config`, `basilisk-stubs`, `basilisk-uv`, `basilisk-common`, `basilisk-buildinfo`, `basilisk-profiler-helper`, `basilisk-profiler-protocol` | +| **Typeshed 下载** | `basilisk-typeshed-fetch` — 工作区中唯一的 HTTP 客户端;仅在用户显式操作时下载 typeshed,与类型检查严格隔离 | +| **测试基础设施** | `basilisk-test-utils`, `basilisk-test-macros` | | **编辑器扩展** | VS Code (`vscode-extension`), Neovim (`basilisk.nvim`), Zed (`basilisk-zed`) | | **未来** | `basilisk-mojo`(所有权),`basilisk-compiler`(原生),WASM 插件 | diff --git a/website/src/zh/docs/install-cli.md b/website/src/zh/docs/install-cli.md new file mode 100644 index 00000000..cecd9ae2 --- /dev/null +++ b/website/src/zh/docs/install-cli.md @@ -0,0 +1,109 @@ +--- +layout: layouts/docs.njk +title: 安装 Basilisk CLI——PyPI、Homebrew、Scoop 与二进制文件 +description: 将 Basilisk Python 类型检查器作为独立 CLI 安装——通过 PyPI(uv tool install 或 pipx)、Homebrew、Scoop、预构建二进制文件或从源代码构建。单个 Rust 二进制文件,无运行时依赖——非常适合 CI。 +keywords: basilisk, cli, pypi, pip, uv, pipx, homebrew, scoop, 二进制文件, 安装, rust, python类型检查器, ci, github actions +lang: zh +date: 2026-02-28 +dateModified: 2026-07-19 +--- + +# CLI 与包管理器 + +当您只想要 `basilisk` 二进制文件本身时——用于命令行、用于 CI,或者用来支撑一个连接系统安装的编辑器——请使用这些安装方式。Basilisk 是一个单一的 Rust 二进制文件,没有运行时依赖:无需 Node.js,无需 Python 解释器,安装后也不需要包管理器。 + +> 使用 **VS Code、Cursor 或 Windsurf**?二进制文件已捆绑在扩展中——参见 [VS Code 与 Cursor](/zh/docs/install-vscode/)。使用 **Zed**?二进制文件随扩展下载——参见 [Zed](/zh/docs/install-zed/)。两者都不需要单独安装 CLI。 + +## PyPI(uv、pipx) + +wheel 包 [`basilisk-python`](https://pypi.org/project/basilisk-python/) 捆绑的是与 Homebrew、Scoop 和 GitHub Releases 相同的原生 `basilisk` CLI——由同一份源码、同一版本构建,只是走独立的发布任务。请将其作为独立工具安装,这样 `basilisk` 命令会进入 PATH,而不会影响任何项目环境: + +```bash +uv tool install basilisk-python +# 或 +pipx install basilisk-python +``` + +安装后的命令仍然是 `basilisk`(发行版之所以命名为 `basilisk-python`,只是因为 PyPI 上的 [`basilisk`](https://pypi.org/project/basilisk/) 这个名字已被一个无关项目占用)。wheel 发布平台:Linux(x86_64、aarch64)、macOS(Apple Silicon)和 Windows(x64、arm64)。wheel 中不含任何 Python 代码——没有包装脚本,也没有 console-script 入口点,只有那个独立的 Rust 二进制文件——因此适用于任何满足该发行版 `requires-python = ">=3.8"` 的 CPython 或 PyPy。Intel 版 macOS 不在任何渠道的发布目标之列(没有 wheel、没有发布归档、也没有 Homebrew bottle),请在该平台上[从源代码构建](#从源代码构建)。 + +## Homebrew(macOS、Linux) + +```bash +brew tap Nimblesite/tap +brew install basilisk +``` + +在 macOS (Apple Silicon) 和 Linux (x86_64、aarch64) 上安装最新发布的 `basilisk` 二进制文件。使用 `brew upgrade basilisk` 升级。 + +## Scoop(Windows) + +```powershell +scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket +scoop install basilisk +``` + +在 Windows (x86_64 和 arm64) 上安装最新发布的 `basilisk.exe`。使用 `scoop update basilisk` 升级。 + +## 预构建二进制文件 + +从 [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases) 下载适合您平台的最新版本: + +```bash +# macOS (Apple Silicon) +curl -sSfL -o basilisk.zip https://github.com/Nimblesite/Basilisk/releases/latest/download/basilisk-aarch64-apple-darwin.zip +unzip basilisk.zip && sudo mv basilisk-darwin/basilisk /usr/local/bin/ + +# Linux (x86_64) +curl -sSfL https://github.com/Nimblesite/Basilisk/releases/latest/download/basilisk-x86_64-unknown-linux-gnu.tar.gz | tar xz +sudo mv basilisk /usr/local/bin/ + +# Linux (aarch64) +curl -sSfL https://github.com/Nimblesite/Basilisk/releases/latest/download/basilisk-aarch64-unknown-linux-gnu.tar.gz | tar xz +sudo mv basilisk /usr/local/bin/ +``` + +验证安装: + +```bash +basilisk --version +``` + +## 从源代码构建 + +```bash +git clone https://github.com/Nimblesite/Basilisk +cd Basilisk +cargo build --release +``` + +二进制文件构建在 `target/release/basilisk`。将其添加到您的 PATH: + +```bash +cp target/release/basilisk /usr/local/bin/ +``` + +需要 Rust 1.87+。 + +## CI 集成 + +Basilisk 自然地集成到任何 CI 管道中。在您的工作流程中下载二进制文件: + +```yaml +# GitHub Actions 示例 +- name: 安装 Basilisk + run: | + curl -sSfL https://github.com/Nimblesite/Basilisk/releases/latest/download/basilisk-x86_64-unknown-linux-gnu.tar.gz | tar xz + sudo mv basilisk /usr/local/bin/ + +- name: 类型检查 + run: basilisk check src/ +``` + +在已经有 `uv` 的管道中,`uv tool install basilisk-python` 与下载发布二进制文件同样好用。 + +退出代码: + +- `0` — 未发现错误 +- `1` — 发现类型错误 +- `2` — 配置错误 +- `3` — 内部错误 diff --git a/website/src/zh/docs/install-neovim.md b/website/src/zh/docs/install-neovim.md new file mode 100644 index 00000000..7dea0dd2 --- /dev/null +++ b/website/src/zh/docs/install-neovim.md @@ -0,0 +1,120 @@ +--- +layout: layouts/docs.njk +title: Neovim 版 Basilisk——安装与更新 basilisk.nvim +description: 使用 basilisk.nvim 在 Neovim 中安装 Basilisk Python 语言服务器。二进制文件自动下载;用 :BasiliskUpdate 在编辑器内更新。通过内置 LSP 客户端提供诊断、自动补全、调试、测试和性能分析。 +keywords: basilisk, neovim, nvim, basilisk.nvim, python, 语言服务器, lsp, 安装, 更新, lazy.nvim, packer, vim-plug, nvim-dap +lang: zh +date: 2026-07-11 +dateModified: 2026-07-11 +--- + +# Neovim 版 Basilisk + +[`basilisk.nvim`](https://github.com/Nimblesite/basilisk.nvim) 将 Neovim 内置的 LSP 客户端(0.11+,通过 `vim.lsp.config` / `vim.lsp.enable`)连接到 Basilisk 语言服务器。一个插件覆盖整个工作流:诊断、悬停、自动补全、跳转到定义、重命名、代码操作、格式化、内联提示、调试(通过 nvim-dap)、测试浏览器和性能分析。 + +需要安装两个部分——**插件**(通过您的插件管理器)和 **`basilisk` 二进制文件**(自动下载;通常您永远不需要自己安装它)。 + +## 1. 安装插件 + +**lazy.nvim** + +```lua +{ + "Nimblesite/basilisk.nvim", + ft = "python", + dependencies = { "mfussenegger/nvim-dap" }, -- 可选,用于调试 + opts = {}, +} +``` + +**packer.nvim** + +```lua +use { + "Nimblesite/basilisk.nvim", + ft = "python", + config = function() + require("basilisk").setup({}) + end, +} +``` + +**vim-plug** + +```vim +Plug 'Nimblesite/basilisk.nvim' +``` + +然后,在 `plug#end()` 之后: + +```lua +lua require("basilisk").setup({}) +``` + +**vim.pack(内置,Neovim 0.12+)** + +```lua +vim.pack.add({ + { src = "https://github.com/Nimblesite/basilisk.nvim", + version = vim.version.range("*") }, -- 最新稳定标签 +}) +require("basilisk").setup({}) +``` + +## 2. 二进制文件随插件一同提供 + +**您不需要单独安装 Basilisk 二进制文件。** 打开一个 Python 文件:如果找不到 `basilisk` 二进制文件,插件会从 [GitHub 发布页](https://github.com/Nimblesite/Basilisk/releases)下载适合您平台的最新版本,缓存到 Neovim 的数据目录中,然后启动语言服务器。您也可以用 `:BasiliskInstall` 显式触发这一过程。 + +已经安装了 Basilisk——通过 [Homebrew、Scoop 或 cargo](/zh/docs/install-cli/),或者它已在您的 `PATH` 上?插件会找到并使用那个安装。 + +用 `:checkhealth basilisk` 验证一切是否正常。 + +## 更新 + +插件和二进制文件分别更新: + +- **插件**——和其他插件一样:`:Lazy update`、`:PackerSync` 或 `:PlugUpdate`。 +- **二进制文件**——当存在更新的发布版本时,启动时会有提示。运行 **`:BasiliskUpdate`**:它会请求确认、下载新版本并就地重启 LSP。如果您的二进制文件由某个包管理器管理,Basilisk 绝不会覆盖它——提示会给出正确的命令(`brew upgrade basilisk`、`scoop update basilisk` 或 `cargo install basilisk-cli`)。 + +## 配置 Basilisk 设置 + +开箱即可零配置工作。要调整行为,请向 `setup()` 传入选项: + +```lua +require("basilisk").setup({ + analysis_mode = "wholeModule", -- "openFilesOnly" | "wholeModule" | "crossModule" + inlay_hints = { + parameter_names = true, + variable_types = true, + }, + formatter = "ruff", -- 内嵌于 basilisk 二进制文件;或 "none" +}) +``` + +完整的选项列表(调试器、测试浏览器、uv 集成、按键映射、状态栏)在插件的帮助文件中:`:h basilisk-configuration`。 + +## 调试、测试与性能分析 + +- **调试**——安装 [nvim-dap](https://github.com/mfussenegger/nvim-dap) 后,`:BasiliskDebugFile`(或您的 DAP 按键映射)会通过 `debugpy` 调试当前文件。参见[调试](/zh/docs/debugging/)。 +- **测试浏览器**——`:BasiliskTestToggle` 打开测试面板;`:BasiliskTestRun` 运行测试。 +- **性能分析**——`:BasiliskProfile` / `:BasiliskProfileStop` 驱动内置分析器。参见[性能分析](/zh/docs/profiler/)指南。 + +所有命令都列在 `:h basilisk-commands` 中。 + +## 高级:覆盖二进制文件 + +对于开发版构建或特定的系统安装,请让插件指向一个显式路径: + +```lua +require("basilisk").setup({ + binary_path = "/absolute/path/to/basilisk", +}) +``` + +……或者设置 `BASILISK_PATH` 环境变量。该设置优先;两者都未设置时,插件使用上文的解析顺序。 + +## 后续步骤 + +- [快速开始](/zh/docs/quick-start/)——您的第一次类型检查 +- [配置](/zh/docs/configuration/)——`pyproject.toml` 参考 +- [重构](/zh/docs/refactoring/)——提取、内联、移动等 diff --git a/website/src/zh/docs/install-vscode.md b/website/src/zh/docs/install-vscode.md new file mode 100644 index 00000000..e483b559 --- /dev/null +++ b/website/src/zh/docs/install-vscode.md @@ -0,0 +1,59 @@ +--- +layout: layouts/docs.njk +title: 最好的 Python VS Code 扩展?安装 Basilisk +description: 在寻找最好的 Python VS Code 扩展?先看看 Basilisk 适合哪些场景,然后安装它捆绑的类型检查器、LSP、调试器、性能分析器和重构工具。 +keywords: 最好的python vscode扩展, vscode的python扩展, basilisk, vs code, cursor, windsurf, open vsx, python语言服务器, 安装, vsix +lang: zh +date: 2026-02-28 +dateModified: 2026-03-31 +--- + +# VS Code、Cursor 与 Windsurf + +从您编辑器的市场安装 **Basilisk** 扩展: + +1. 打开您的编辑器 +2. 进入扩展(`Ctrl+Shift+X` / `Cmd+Shift+X`) +3. 搜索 **Basilisk** +4. 点击**安装** + +该扩展已发布到 **[VS Code 市场](https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk)** 和 **[Open VSX](https://open-vsx.org/extension/Nimblesite/basilisk)**,因此可以安装在 **VS Code**、**Cursor**、**Windsurf** 以及其他兼容 VS Code 的编辑器中。 + +打开一个 Python 文件,Basilisk 会自动激活——诊断、自动补全、悬停、跳转到定义、重命名、重构、格式化、调试(F5)和性能分析。 + +![Basilisk 在 VS Code 中——符合 PEP 规范的类型错误以红色波浪线内联显示,并列在问题面板中](/assets/images/vscode-diagnostics.png) + +*打开文件的瞬间即可获得符合 PEP 规范的诊断——无需任何配置。* + +## Basilisk 是适合您的最佳 Python VS Code 扩展吗? + +没有哪个 Python 扩展适合所有项目。Basilisk 面向的开发者,是希望用一个开源扩展同时获得符合类型规范的检查、自动补全、导航、重构、格式化、调试和性能分析——并且在 VS Code 之外也能使用同一个语言服务器的人。如果您的项目依赖成熟的 mypy 框架插件,或者您更偏好 Pylance 已经成型的、仅限 VS Code 的工作流,请在切换前阅读[Python 类型检查器对比](/zh/docs/comparison/)。 + +## 二进制文件已捆绑——无需单独安装 + +**扩展在 VSIX 内部附带了适合您平台的 Basilisk 二进制文件。** 默认安装无需额外设置:无需 `cargo install`,无需配置 PATH,也无需手动下载。 + +| 操作系统 | 架构 | +|----|-------------| +| macOS | Apple Silicon (aarch64) | +| Linux | x86_64 | +| Linux | aarch64 | +| Windows | x86_64 | +| Windows | arm64 | + +## 扩展如何找到二进制文件 + +扩展按以下顺序解析二进制文件: + +1. **显式组件路径**——`basilisk.binaries.basilisk` 或 `basilisk.executablePath` +2. **显式二进制目录**——`basilisk.binaries.path` +3. **捆绑的 VSIX 二进制文件**——`bin//basilisk`(默认) +4. **外部安装**——Cargo、Homebrew、Scoop 或 PATH,前提是版本匹配 + +Homebrew 和 Scoop 是外部覆盖或修复来源。默认安装运行的是 VSIX 内捆绑的二进制文件。仅当您有意覆盖捆绑的二进制文件时——例如要运行本地构建的开发版二进制文件——才需要使用 `basilisk.executablePath`、`basilisk.binaries.basilisk` 或 `basilisk.binaries.path`。 + +## 后续步骤 + +- [快速开始](/zh/docs/quick-start/)——您的第一次类型检查 +- [调试](/zh/docs/debugging/)——按 F5 开始调试 +- [配置](/zh/docs/configuration/)——`pyproject.toml` 参考 diff --git a/website/src/zh/docs/install-zed.md b/website/src/zh/docs/install-zed.md new file mode 100644 index 00000000..89568fbf --- /dev/null +++ b/website/src/zh/docs/install-zed.md @@ -0,0 +1,102 @@ +--- +layout: layouts/docs.njk +title: Zed 版 Basilisk——安装与使用该 Python 扩展 +description: 在 Zed 编辑器中安装并使用 Basilisk Python 语言服务器。匹配的二进制文件会随扩展自动下载——零配置,无需单独安装。诊断、自动补全、调试和性能分析。 +keywords: basilisk, zed, zed编辑器, python, 语言服务器, lsp, 安装, 扩展, 调试, 性能分析, 斜杠命令 +lang: zh +date: 2026-02-28 +dateModified: 2026-03-31 +--- + +# Zed 版 Basilisk + +Basilisk 提供了一个原生 [Zed](https://zed.dev) 扩展,为 Python 注册 Basilisk 语言服务器。安装后,Basilisk 会为每个 `.py` 文件自动激活——诊断、自动补全、悬停、跳转到定义、重命名、代码操作、格式化、内联提示、调试和性能分析。 + +## 安装扩展 + +1. 打开扩展视图:命令面板(`Cmd+Shift+P`)→ **zed: extensions** +2. 搜索 **Basilisk** +3. 点击 **Install** + +就这样。打开一个 Python 文件,Basilisk 就是您的语言服务器。 + +> **从源代码安装?** 如果您正在存储库的检出目录中工作,请改为将其安装为开发扩展:命令面板 → **zed: install dev extension** → 选择 `basilisk-zed/` 目录。Zed 会自动将扩展编译为 WASM——您永远不需要预构建或复制 `.wasm` 文件。 + +## 二进制文件随扩展一同提供 + +**您不需要单独安装 Basilisk 二进制文件。** 首次激活时,扩展会直接从 [GitHub 发布页](https://github.com/Nimblesite/Basilisk/releases)下载适合您平台的匹配二进制文件,将其缓存在 Zed 的扩展目录中,并一直复用到有更新的发布版本出现为止。无需 `cargo install`,无需 Homebrew,无需设置 PATH——安装扩展就是全部流程。 + +当有更新的发布版本可用时,扩展会记录一条更新提示;重启 Zed 即可采用。 + +## 配置 Basilisk 设置 + +Basilisk 零配置即可工作。要调整其行为,请在您的 Zed `settings.json` 中的 `lsp.basilisk.settings` 下添加设置: + +```json +{ + "lsp": { + "basilisk": { + "settings": { + "analysisMode": "wholeModule" + } + } + }, + "languages": { + "Python": { + "language_servers": ["basilisk", "..."] + } + } +} +``` + +> 语言服务器目前仅识别 `analysisMode`(`wholeModule` 或 `openFilesOnly`)和 +> `testExplorer` 设置。其他键会被接受,但服务器尚未读取——请参阅 +> [配置参考](/zh/docs/configuration/)了解当前实际生效的设置。 + +## 调试 + +在 Python 文件上按 **F5** 即可调试。Basilisk 通过调试适配器协议代理一个 `debugpy` 会话——断点、单步执行、变量、调用栈和监视表达式在 Zed 中都能原生工作。会话的代理方式请参见[调试](/zh/docs/debugging/)。 + +## 斜杠命令 + +Basilisk 在 Zed 的 AI 助手面板中注册了用于性能分析、内存分析、测试和工作区洞察的斜杠命令。性能分析和内存命令是**指南**:每条命令都会说明对应的 `basilisk.profiler.*` / `basilisk.memory.*` 语言服务器命令以及如何驱动它——性能分析本身通过 LSP 运行: + +| 命令 | 作用 | +|---------|--------------| +| `/profile` | 如何启动 CPU 性能分析(可选 PID) | +| `/profstop` | 如何停止分析以及结果保存在哪里 | +| `/profsnapshot` | 如何在不停止的情况下快照热点 | +| `/memleak` | 通过 `tracemalloc` 的内存跟踪工作流 | +| `/memstop` | 如何停止内存跟踪 | +| `/memrefs ` | 如何遍历某个 Python 类型的引用图 | +| `/tests` | 发现 pytest/unittest 测试 | +| `/runtests` | 按节点 ID 或文件运行测试 | +| `/testfile` | 运行当前文件中的所有测试 | +| `/modules` | 显示工作区模块树 | +| `/symbols ` | 显示某个模块中的符号 | +| `/health` | 类型覆盖率健康统计 | +| `/basilisk` | 服务器信息与命令参考 | + +完整的性能分析工作流请参见[性能分析](/zh/docs/profiler/)指南。 + +## 高级:覆盖二进制文件 + +只有在开发时(运行本地构建的二进制文件)或想让 Zed 指向系统安装时才需要这样做。您可以在 `settings.json` 中显式设置路径: + +```json +{ + "lsp": { + "basilisk": { + "binary": { "path": "/absolute/path/to/basilisk" } + } + } +} +``` + +……或者设置 `BASILISK_PATH` 环境变量。该设置优先于环境变量;两者都未设置时,扩展会下载发布版二进制文件(即上文的默认行为)。 + +## 后续步骤 + +- [快速开始](/zh/docs/quick-start/)——您的第一次类型检查 +- [重构](/zh/docs/refactoring/)——提取、内联、移动等 +- [配置](/zh/docs/configuration/)——`pyproject.toml` 参考 diff --git a/website/src/zh/docs/installation.md b/website/src/zh/docs/installation.md index 66e82672..1a711aa6 100644 --- a/website/src/zh/docs/installation.md +++ b/website/src/zh/docs/installation.md @@ -1,228 +1,55 @@ --- layout: layouts/docs.njk -title: 安装 -description: 如何安装 Basilisk——通过 PyPI(uv 或 pipx)、Homebrew、Scoop、预构建二进制文件、VS Code 扩展、Zed 扩展或从源代码构建。 -keywords: basilisk, 安装, pypi, pip, uv, pipx, homebrew, scoop, rust, python类型检查器, vs code, zed +title: 安装 Basilisk——VS Code、Cursor、Zed、Neovim 或 CLI +description: 为您的编辑器安装 Basilisk Python 语言服务器——VS Code、Cursor、Windsurf、Zed 或 Neovim——或作为独立 CLI 通过 PyPI(uv tool install 或 pipx)、Homebrew、Scoop 或预构建二进制文件安装。单个 Rust 二进制文件,无运行时依赖。 +keywords: basilisk, 安装, 最好的python类型检查器, vs code, cursor, windsurf, zed, neovim, pypi, pip, uv, pipx, homebrew, scoop, open vsx, python语言服务器, rust lang: zh +date: 2026-02-28 +dateModified: 2026-07-19 --- # 安装 -Basilisk 是一个单一的 Rust 二进制文件,没有运行时依赖。无需 Node.js。无需 Python 解释器。安装后不需要包管理器。 +Basilisk 是一个单一的 Rust 二进制文件,没有运行时依赖——无需 Node.js,无需 Python 解释器,安装后也不需要包管理器。**在每一款受支持的编辑器中,二进制文件都随扩展一同提供;您永远不需要单独安装它。** -## VS Code 扩展(推荐) +选择您的环境: -最快的入门方式。从 VS Code 市场安装 **Basilisk** 扩展: +| 如果您使用…… | 安装指南 | 二进制文件…… | +|---|---|---| +| **VS Code、Cursor、Windsurf** | [VS Code 与 Cursor](/zh/docs/install-vscode/) | 捆绑在扩展内部 | +| **Zed** | [Zed](/zh/docs/install-zed/) | 首次运行时随扩展下载 | +| **Neovim** | [Neovim](/zh/docs/install-neovim/) | 首次使用时由插件下载 | +| **命令行 / CI** | [CLI 与包管理器](/zh/docs/install-cli/) | 通过 PyPI(`uv tool install`)、Homebrew、Scoop 或发布二进制文件安装 | -1. 打开 VS Code -2. 进入扩展(`Ctrl+Shift+X` / `Cmd+Shift+X`) -3. 搜索 **Basilisk** -4. 点击**安装** +## 编辑器支持(LSP) -**扩展会捆绑适合您平台的 Basilisk 二进制文件。** 默认 VSIX 安装无需手动设置。 +Basilisk 实现了语言服务器协议,因此任何支持 LSP 的编辑器都可以使用它: -```bash -git clone https://github.com/Nimblesite/Basilisk -cd basilisk -cargo build --release -``` +- **VS Code**——官方扩展,捆绑二进制文件 → [指南](/zh/docs/install-vscode/) +- **Cursor、Windsurf 及其他 VS Code 分支**——通过 [Open VSX](https://open-vsx.org) → [指南](/zh/docs/install-vscode/) +- **Zed**——原生扩展,自动下载二进制文件 → [指南](/zh/docs/install-zed/) +- **Neovim**——官方 `basilisk.nvim` 插件,自动下载二进制文件 → [指南](/zh/docs/install-neovim/) +- **Helix**——原生 LSP 支持(将其指向一个 [CLI 安装](/zh/docs/install-cli/)) +- **Emacs**——通过 eglot 或 lsp-mode(将其指向一个 [CLI 安装](/zh/docs/install-cli/)) +- **JetBrains(IntelliJ / PyCharm)**——即将支持 -| 操作系统 | 架构 | -|----|-------------| -| macOS | Apple Silicon (aarch64) | -| Linux | x86_64 | -| Linux | aarch64 | -| Windows | x86_64 | -| Windows | arm64 | +## 各编辑器的集成状态 -仅当您明确想覆盖 VSIX 中捆绑的二进制文件时,才需要设置 `basilisk.executablePath`、`basilisk.binaries.basilisk` 或 `basilisk.binaries.path`。 +完整的 Basilisk 工作流在各编辑器中的现状——✅ 已交付,🌗 部分支持,⛔️ 尚未支持: -## PyPI(uv、pipx) +| IDE | 用户估算 | 已发布 | LSP | 格式化 | 性能分析 | 内存 | 调试 | 测试 | MCP | +|--------------------------------|:----------:|:--------:|:---:|:------:|:---------:|:------:|:---------:|:-------:|:---:| +| VS Code | [5000 万月活](https://developer.microsoft.com/blog/celebrating-50-million-developers-the-journey-of-visual-studio-and-visual-studio-code) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⛔️ | +| IntelliJ / PyCharm | [1140 万](https://www.jetbrains.com/lp/annualreport-2024/) | ⛔️ | ⛔️ | 🌗 | ⛔️ | ⛔️ | ⛔️ | ⛔️ | ⛔️ | +| OpenVSX(Cursor、Windsurf 等) | [100 万+ 日活](https://cursor.com/blog/series-d) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⛔️ | +| Emacs | [约 100 万](https://en.wikipedia.org/wiki/Emacs) | ⛔️ | ⛔️ | 🌗 | ⛔️ | ⛔️ | ⛔️ | ⛔️ | ⛔️ | +| Vim | [约占命令行用户的 1/3](https://en.wikipedia.org/wiki/Vim_(text_editor)) | ⛔️ | ⛔️ | 🌗 | ⛔️ | ⛔️ | ⛔️ | ⛔️ | ⛔️ | +| Sublime Text | [约 1.5% 市场份额](https://6sense.com/tech/ides-and-text-editors/sublime-text-market-share) | ⛔️ | ⛔️ | 🌗 | ⛔️ | ⛔️ | ⛔️ | ⛔️ | ⛔️ | +| Zed | [每日数十万](https://en.wikipedia.org/wiki/Zed_(text_editor)) | ✅ | ⛔️ | 🌗 | ✅ | ✅ | ✅ | ✅ | ⛔️ | +| Neovim | [约 18 万](https://en.wikipedia.org/wiki/Neovim) | 🌗 | ✅ | ⛔️ | 🌗 | ✅ | ✅ | 🌗 | ⛔️ | -wheel 包 [`basilisk-python`](https://pypi.org/project/basilisk-python/) 捆绑的是与 Homebrew、Scoop 和 GitHub Releases 相同的原生 `basilisk` CLI——由同一份源码、同一版本构建,只是走独立的发布任务。请将其作为独立工具安装,这样 `basilisk` 命令会进入 PATH,而不会影响任何项目环境: +用户估算均链接到其出处,取自各平台自行公布的数字,而非我们的测量结果。 -```bash -uv tool install basilisk-python -# 或 -pipx install basilisk-python -``` +## 后续步骤 -安装后的命令仍然是 `basilisk`(发行版之所以命名为 `basilisk-python`,只是因为 PyPI 上的 [`basilisk`](https://pypi.org/project/basilisk/) 这个名字已被一个无关项目占用)。wheel 发布平台:Linux(x86_64、aarch64)、macOS(Apple Silicon)和 Windows(x64、arm64)。wheel 中不含任何 Python 代码——没有包装脚本,也没有 console-script 入口点,只有那个独立的 Rust 二进制文件——因此适用于任何满足该发行版 `requires-python = ">=3.8"` 的 CPython 或 PyPy。Intel 版 macOS 不在任何渠道的发布目标之列(没有 wheel、没有发布归档、也没有 Homebrew bottle),请在该平台上从源码构建。 - -## Homebrew (macOS、Linux) - -```bash -brew tap Nimblesite/tap -brew install basilisk -``` - -在 macOS (Apple Silicon) 和 Linux (x86_64、aarch64) 上安装最新发布的 `basilisk` 二进制文件。使用 `brew upgrade basilisk` 升级。 - -## Scoop (Windows) - -```powershell -scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket -scoop install basilisk -``` - -在 Windows (x86_64 和 arm64) 上安装最新发布的 `basilisk.exe`。使用 `scoop update basilisk` 升级。 - -## 预构建二进制文件 - -从 [GitHub Releases](https://github.com/Nimblesite/Basilisk/releases) 下载适合您平台的最新版本: - -```bash -# macOS (Apple Silicon) -curl -sSfL -o basilisk.zip https://github.com/Nimblesite/Basilisk/releases/latest/download/basilisk-aarch64-apple-darwin.zip -unzip basilisk.zip && sudo mv basilisk /usr/local/bin/ - -# Linux (x86_64) -curl -sSfL https://github.com/Nimblesite/Basilisk/releases/latest/download/basilisk-x86_64-unknown-linux-gnu.tar.gz | tar xz -sudo mv basilisk /usr/local/bin/ - -# Linux (aarch64) -curl -sSfL https://github.com/Nimblesite/Basilisk/releases/latest/download/basilisk-aarch64-unknown-linux-gnu.tar.gz | tar xz -sudo mv basilisk /usr/local/bin/ -``` - -验证安装: - -```bash -basilisk --version -``` - -## 从源代码构建 - -```bash -git clone https://github.com/Nimblesite/Basilisk -cd Basilisk -cargo build --release -``` - -二进制文件构建在 `target/release/basilisk`。将其添加到您的 PATH: - -```bash -cp target/release/basilisk /usr/local/bin/ -``` - -需要 Rust 1.87+。 - -在 [github.com/Nimblesite/Basilisk](https://github.com/Nimblesite/Basilisk) 跟踪进度。 - -## CI 集成 - -Basilisk 自然地集成到任何 CI 管道中。在您的工作流程中下载二进制文件: - -```yaml -# GitHub Actions 示例 -- name: 安装 Basilisk - run: | - curl -sSfL https://github.com/Nimblesite/Basilisk/releases/latest/download/basilisk-x86_64-unknown-linux-gnu.tar.gz | tar xz - sudo mv basilisk /usr/local/bin/ - -- name: 类型检查 - run: basilisk check src/ -``` - -退出代码: -- `0` — 未发现错误 -- `1` — 发现类型错误 -- `2` — 配置错误 -- `3` — 内部错误 - -## Zed 扩展 - -Basilisk 提供了一个原生 Zed 扩展,为 Python 文件注册 LSP。安装后,Basilisk 自动激活为所有 `.py` 文件的语言服务器。 - -### 安装扩展 - -1. 构建并安装 Basilisk CLI 二进制文件: - -```bash -cargo install --path crates/basilisk-cli -``` - -这将二进制文件安装到 `~/.cargo/bin/basilisk`。 - -2. 在 Zed 中安装开发扩展: - -- 打开命令面板:`Cmd+Shift+P` -- 运行 **zed: install dev extension** -- 从存储库中选择 `basilisk-zed/` 目录 - -Zed 自动将扩展的 Rust 源代码编译为 WASM。您不需要预构建或复制任何 `.wasm` 文件。 - -3. 打开一个 Python 文件——Basilisk 现在是您的 Python 语言服务器。 - -### Zed 如何找到二进制文件 - -Zed 扩展按以下顺序解析 Basilisk 二进制文件: - -1. **Zed LSP 设置** — 如果您在 Zed `settings.json` 中配置了显式路径: - -```json -{ - "lsp": { - "basilisk": { - "binary": { - "path": "/path/to/basilisk" - } - } - } -} -``` - -2. **`BASILISK_PATH` 环境变量** — 设置此变量以覆盖默认位置 -3. **`~/.cargo/bin/basilisk`** — `cargo install` 放置二进制文件的默认位置 - -Zed **不**从 PATH 解析裸命令名。扩展始终返回二进制文件的绝对路径。 - -### 在 Zed 中配置 Basilisk 设置 - -将 Basilisk 特定设置添加到您的 Zed `settings.json`: - -```json -{ - "lsp": { - "basilisk": { - "settings": { - "analysisMode": "wholeModule" - } - } - } -} -``` - -> 语言服务器目前仅识别 `analysisMode`(`wholeModule` 或 `openFilesOnly`)和 -> `testExplorer` 设置。其他设置尚未被服务器读取——请参阅 -> [配置参考](/zh/docs/configuration/)了解当前实际生效的设置。 - -### 更改后重新构建 - -如果您修改了 Basilisk 源代码: - -1. 重新构建 CLI 二进制文件:`cargo install --path crates/basilisk-cli --force` -2. 在 Zed 中重新安装开发扩展:`Cmd+Shift+P` → **zed: install dev extension** → 选择 `basilisk-zed/` - -Zed 自动重新编译 WASM 并重新加载扩展。 - -## 编辑器支持 (LSP) - -Basilisk 实现了语言服务器协议。任何支持 LSP 的编辑器都可以使用它: - -- **VS Code** — 通过官方 Basilisk 扩展(捆绑匹配的二进制文件) -- **Zed** — 通过 Basilisk Zed 扩展(见上文) -- **Neovim** — 通过 nvim-lspconfig -- **Helix** — 原生 LSP 支持 -- **Emacs** — 通过 eglot 或 lsp-mode - -## VS Code 扩展如何找到二进制文件 - -扩展按以下顺序解析 Basilisk 二进制文件: - -1. **显式组件路径** — `basilisk.binaries.basilisk` 或 `basilisk.executablePath` -2. **显式二进制目录** — `basilisk.binaries.path` -3. **捆绑的 VSIX 二进制文件** — `bin//basilisk` -4. **外部安装** — Cargo、Homebrew、Scoop 或 PATH,前提是版本匹配 - -Homebrew 和 Scoop 是外部覆盖或修复来源。默认 VSIX 安装会运行 VSIX 内捆绑的二进制文件。 +安装完成后,请前往[快速开始](/zh/docs/quick-start/)运行您的第一次类型检查,或查看[配置参考](/zh/docs/configuration/)在 `pyproject.toml` 中调整 Basilisk。 diff --git a/website/src/zh/docs/quick-start.md b/website/src/zh/docs/quick-start.md index 14972cff..33e707af 100644 --- a/website/src/zh/docs/quick-start.md +++ b/website/src/zh/docs/quick-start.md @@ -226,15 +226,14 @@ result: Any = legacy_sdk_call() # type: ignore[returns_compatibility] 没有原因的抑制本身会被标记。这是故意的:如果您需要抑制诊断,您应该能够解释原因。 -## 第 8 步——检查统计 +## 第 8 步——查看类型覆盖率 -获取项目的类型覆盖率报告: +类型覆盖率在编辑器中实时呈现。点击活动栏中的 **Basilisk** 图标并展开 **模块浏览器** +(Module Explorer):每个包和文件都会显示一条覆盖率条和百分比——即其中已添加注解的符号 +所占比例——旁边还有各自的错误与警告计数;图标按覆盖率着色,因此项目中缺少类型的角落 +一眼可见。选中某个模块即可打开它。 -```bash -basilisk stats src/ -``` - -输出包括:总函数数、已类型化的函数数、类型覆盖率百分比、无注解的文件。 +覆盖率会沿树向上聚合,因此包节点显示的是其下所有内容的合计数值。 ## 第 9 步——分析运行中的脚本 diff --git a/website/src/zh/docs/refactoring.md b/website/src/zh/docs/refactoring.md index ee767cd6..5dbfc34e 100644 --- a/website/src/zh/docs/refactoring.md +++ b/website/src/zh/docs/refactoring.md @@ -10,6 +10,10 @@ lang: zh Basilisk 通过 LSP 协议提供**完整的重构代码操作套件**。它们自动出现在 VS Code、Zed 和 Neovim 的灯泡菜单中(通过 Open VSX 的 Cursor/Windsurf 即将推出)——无需额外的扩展或配置。 +![VS Code 中的 Basilisk 快速修复菜单——全部修复、添加注解、降级或禁用规则,以及移动函数重构](/assets/images/vscode-quickfix.png) + +*Basilisk 诊断上的快速修复菜单(`Cmd/Ctrl+.`):自动修复、逐规则控制和重构。* + 每个重构都会产生一个编辑器原子性应用的 `WorkspaceEdit`。多文件重构(移动符号、模块重命名)使用带有 `CreateFile` 操作的 `DocumentChanges`。 ## 提取 @@ -163,6 +167,17 @@ Basilisk 的重命名是**范围感知的**——在一个函数内重命名 `x` | 三元 ↔ if/else | `x = a if cond else b` 变为 4 行 if/else 块 | | NamedTuple 类 ↔ 函数式 | 类语法变为 `namedtuple()` 调用,反之亦然 | +## 模块重命名(workspace/willRenameFiles) + +当在编辑器中重命名 `.py` 文件时,Basilisk 会在整个工作区中重写所有引用旧模块路径的 `import` 和 `from ... import` 语句。这处理: + +- `import old.module` → `import new.module` +- `from old.module import name` → `from new.module import name` +- `__init__.py` → 包名映射 +- 嵌套子模块路径 + +> **注意:** 此功能需要 LSP 客户端支持 `workspace/willRenameFiles`。目前受阻于 tower-lsp 0.21+ 的能力注册;处理程序逻辑已完全实现。 + ## 竞争对比 | 功能 | Basilisk | Pylance | Pyright | Jedi-LSP | pylsp + Rope |